project binary-analysis-tool complete

This commit is contained in:
CarterPerez-dev 2026-03-30 15:28:16 -04:00
parent f1d6dc4b32
commit 0bfdd8d247
127 changed files with 25480 additions and 0 deletions

View File

@ -0,0 +1,19 @@
# ©AngelaMos | 2026
# .env.example
# Copy to .env (production) or .env.development (dev) and adjust values
APP_NAME=axumortem
VITE_API_URL=/api
VITE_APP_TITLE=axumortem
NGINX_HOST_PORT=22784
FRONTEND_HOST_PORT=15723
BACKEND_HOST_PORT=3000
POSTGRES_HOST_PORT=5432
POSTGRES_PASSWORD=changeme
RUST_LOG=info
MAX_UPLOAD_SIZE=52428800
CORS_ORIGIN=*
# CLOUDFLARE_TUNNEL_TOKEN=your-token-here

View File

@ -0,0 +1,24 @@
# ©AngelaMos | 2026
# .gitignore
# Rust
backend/target/
# Environment
.env
.env.development
.env.production
.env.local
!.env.example
# Development docs
docs/
# OS
.DS_Store
Thumbs.db
# Editors
.idea/
.vscode/
*.sw?

View File

@ -0,0 +1,65 @@
```rust
█████╗ ██╗ ██╗██╗ ██╗███╗ ███╗ ██████╗ ██████╗ ████████╗███████╗███╗ ███╗
██╔══██╗╚██╗██╔╝██║ ██║████╗ ████║██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝████╗ ████║
███████║ ╚███╔╝ ██║ ██║██╔████╔██║██║ ██║██████╔╝ ██║ █████╗ ██╔████╔██║
██╔══██║ ██╔██╗ ██║ ██║██║╚██╔╝██║██║ ██║██╔══██╗ ██║ ██╔══╝ ██║╚██╔╝██║
██║ ██║██╔╝ ██╗╚██████╔╝██║ ╚═╝ ██║╚██████╔╝██║ ██║ ██║ ███████╗██║ ╚═╝ ██║
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2320-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/binary-analysis-tool)
[![Rust](https://img.shields.io/badge/Rust-stable-000000?style=flat&logo=rust&logoColor=white)](https://www.rust-lang.org)
[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com)
> Static binary analysis engine with multi-format parsing, YARA scanning, x86 disassembly, and MITRE ATT&CK threat scoring.
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
## What It Does
- Multi-format binary parsing (ELF, PE, Mach-O) with section analysis and import table extraction
- YARA rule scanning with 14 built-in detection rules for malware, packers, and crypto patterns
- x86/x86_64 disassembly with control flow graph generation from entry points and symbol tables
- Shannon entropy analysis for detecting packed or encrypted sections
- 8-category threat scoring system (max 100 points) with MITRE ATT&CK technique mapping
- Pass-based analysis pipeline with topological ordering and dependency resolution
## Quick Start
```bash
docker compose up -d
```
Visit `http://localhost:22784`
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Stack
**Backend:** Rust, Axum, goblin, iced-x86, yara-x, SQLx, PostgreSQL
**Frontend:** React 19, TypeScript, Vite, TanStack Query, Zustand, Zod, SCSS Modules
**Infra:** Docker Compose, Nginx, PostgreSQL 18
## Learn
This project includes step-by-step learning materials covering security theory, architecture, and implementation.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start |
| [01 - Concepts](learn/01-CONCEPTS.md) | Security theory and real-world breaches |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough |
| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises |
## License
AGPL 3.0

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
# ©AngelaMos | 2026
# Cargo.toml
[workspace]
members = ["crates/axumortem", "crates/axumortem-engine"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tokio = { version = "1", features = ["full"] }
sha2 = "0.10"

View File

@ -0,0 +1,26 @@
# ©AngelaMos | 2026
# Cargo.toml
[package]
name = "axumortem-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
goblin = "0.9"
iced-x86 = { version = "1.21", features = [
"decoder",
"intel",
"nasm",
"instr_info",
] }
yara-x = "0.13"
petgraph = "0.7"
memmap2 = "0.9"
rangemap = "1.5"
sha2 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View File

@ -0,0 +1,66 @@
// ©AngelaMos | 2026
// context.rs
use std::sync::Arc;
use memmap2::Mmap;
use crate::formats::FormatResult;
use crate::passes::disasm::DisassemblyResult;
use crate::passes::entropy::EntropyResult;
use crate::passes::imports::ImportResult;
use crate::passes::strings::StringResult;
use crate::passes::threat::ThreatResult;
pub enum BinarySource {
Mapped(Mmap),
Buffered(Arc<[u8]>),
}
impl AsRef<[u8]> for BinarySource {
fn as_ref(&self) -> &[u8] {
match self {
Self::Mapped(mmap) => mmap,
Self::Buffered(buf) => buf,
}
}
}
pub struct AnalysisContext {
source: BinarySource,
pub sha256: String,
pub file_name: String,
pub file_size: u64,
pub format_result: Option<FormatResult>,
pub import_result: Option<ImportResult>,
pub string_result: Option<StringResult>,
pub entropy_result: Option<EntropyResult>,
pub disassembly_result: Option<DisassemblyResult>,
pub threat_result: Option<ThreatResult>,
}
impl AnalysisContext {
pub fn new(
source: BinarySource,
sha256: String,
file_name: String,
file_size: u64,
) -> Self {
Self {
source,
sha256,
file_name,
file_size,
format_result: None,
import_result: None,
string_result: None,
entropy_result: None,
disassembly_result: None,
threat_result: None,
}
}
pub fn data(&self) -> &[u8] {
self.source.as_ref()
}
}

View File

@ -0,0 +1,33 @@
// ©AngelaMos | 2026
// error.rs
#[derive(thiserror::Error, Debug)]
pub enum EngineError {
#[error("invalid binary: {reason}")]
InvalidBinary { reason: String },
#[error("unsupported format: {format}")]
UnsupportedFormat { format: String },
#[error("unsupported architecture: {arch}")]
UnsupportedArchitecture { arch: String },
#[error("pass '{pass}' missing dependency: {dependency}")]
MissingDependency {
pass: String,
dependency: String,
},
#[error("pass '{pass}' failed")]
PassFailed {
pass: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("yara error: {0}")]
Yara(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}

View File

@ -0,0 +1,313 @@
// ©AngelaMos | 2026
// elf.rs
use goblin::elf::dynamic::DT_BIND_NOW;
use goblin::elf::header::{
EM_386, EM_AARCH64, EM_ARM, EM_X86_64, ET_CORE, ET_DYN,
ET_EXEC, ET_REL,
};
use goblin::elf::program_header::{
PF_R, PF_W, PF_X, PT_DYNAMIC, PT_GNU_EH_FRAME,
PT_GNU_RELRO, PT_GNU_STACK, PT_INTERP, PT_LOAD, PT_NOTE,
PT_NULL, PT_PHDR,
};
use goblin::elf::section_header::{
SHF_ALLOC, SHF_EXECINSTR, SHF_WRITE, SHT_NOBITS,
SHT_SYMTAB,
};
use goblin::elf::sym::STT_FUNC;
use goblin::elf::Elf;
use super::{
detect_common_anomalies, compute_section_hash, ElfInfo,
FormatResult, SectionInfo, SegmentInfo,
};
use crate::error::EngineError;
use crate::types::{
Architecture, BinaryFormat, Endianness, SectionPermissions,
};
const EI_OSABI: usize = 7;
const ELFOSABI_NONE: u8 = 0;
const ELFOSABI_HPUX: u8 = 1;
const ELFOSABI_NETBSD: u8 = 2;
const ELFOSABI_GNU: u8 = 3;
const ELFOSABI_SOLARIS: u8 = 6;
const ELFOSABI_FREEBSD: u8 = 9;
const ELFOSABI_OPENBSD: u8 = 12;
const ELFOSABI_ARM: u8 = 97;
const ELFOSABI_STANDALONE: u8 = 255;
const DT_FLAGS: u64 = 30;
const DF_BIND_NOW: u64 = 0x8;
pub fn parse_elf(
elf: &Elf,
data: &[u8],
) -> Result<FormatResult, EngineError> {
let architecture =
map_architecture(elf.header.e_machine);
let bits = if elf.is_64 { 64 } else { 32 };
let endianness = if elf.little_endian {
Endianness::Little
} else {
Endianness::Big
};
let entry_point = elf.header.e_entry;
let has_symtab = elf
.section_headers
.iter()
.any(|sh| sh.sh_type == SHT_SYMTAB);
let is_stripped = !has_symtab;
let has_interp = elf
.program_headers
.iter()
.any(|ph| ph.p_type == PT_INTERP);
let is_pie =
elf.header.e_type == ET_DYN && has_interp;
let has_debug_info =
elf.section_headers.iter().any(|sh| {
elf.shdr_strtab
.get_at(sh.sh_name)
.is_some_and(|name| {
name.starts_with(".debug_")
})
});
let sections = build_sections(elf, data);
let segments = build_segments(elf);
let anomalies = detect_common_anomalies(
&sections,
entry_point,
is_stripped,
);
let elf_info = build_elf_info(elf);
let function_hints =
collect_function_hints(elf, entry_point);
Ok(FormatResult {
format: BinaryFormat::Elf,
architecture,
bits,
endianness,
entry_point,
is_stripped,
is_pie,
has_debug_info,
sections,
segments,
anomalies,
pe_info: None,
elf_info: Some(elf_info),
macho_info: None,
function_hints,
})
}
fn map_architecture(machine: u16) -> Architecture {
match machine {
EM_386 => Architecture::X86,
EM_X86_64 => Architecture::X86_64,
EM_ARM => Architecture::Arm,
EM_AARCH64 => Architecture::Aarch64,
other => {
Architecture::Other(format!(
"elf-machine-{other}"
))
}
}
}
fn os_abi_name(abi: u8) -> String {
match abi {
ELFOSABI_NONE => "SysV".into(),
ELFOSABI_HPUX => "HP-UX".into(),
ELFOSABI_NETBSD => "NetBSD".into(),
ELFOSABI_GNU => "GNU/Linux".into(),
ELFOSABI_SOLARIS => "Solaris".into(),
ELFOSABI_FREEBSD => "FreeBSD".into(),
ELFOSABI_OPENBSD => "OpenBSD".into(),
ELFOSABI_ARM => "ARM".into(),
ELFOSABI_STANDALONE => "Standalone".into(),
other => format!("Unknown({other})"),
}
}
fn elf_type_name(e_type: u16) -> String {
match e_type {
ET_REL => "REL".into(),
ET_EXEC => "EXEC".into(),
ET_DYN => "DYN".into(),
ET_CORE => "CORE".into(),
other => format!("Unknown({other})"),
}
}
fn segment_type_name(p_type: u32) -> Option<String> {
Some(
match p_type {
PT_NULL => "NULL",
PT_LOAD => "LOAD",
PT_DYNAMIC => "DYNAMIC",
PT_INTERP => "INTERP",
PT_NOTE => "NOTE",
PT_PHDR => "PHDR",
PT_GNU_EH_FRAME => "GNU_EH_FRAME",
PT_GNU_STACK => "GNU_STACK",
PT_GNU_RELRO => "GNU_RELRO",
_ => return Some(format!("0x{p_type:x}")),
}
.into(),
)
}
fn build_sections(
elf: &Elf,
data: &[u8],
) -> Vec<SectionInfo> {
elf.section_headers
.iter()
.skip(1)
.map(|shdr| {
let name = elf
.shdr_strtab
.get_at(shdr.sh_name)
.unwrap_or("")
.to_string();
let is_nobits = shdr.sh_type == SHT_NOBITS;
let raw_offset = if is_nobits {
0
} else {
shdr.sh_offset
};
let raw_size = if is_nobits {
0
} else {
shdr.sh_size
};
let permissions = SectionPermissions {
read: (shdr.sh_flags
& u64::from(SHF_ALLOC))
!= 0,
write: (shdr.sh_flags
& u64::from(SHF_WRITE))
!= 0,
execute: (shdr.sh_flags
& u64::from(SHF_EXECINSTR))
!= 0,
};
let sha256 = compute_section_hash(
data, raw_offset, raw_size,
);
SectionInfo {
name,
virtual_address: shdr.sh_addr,
virtual_size: shdr.sh_size,
raw_offset,
raw_size,
permissions,
sha256,
}
})
.collect()
}
fn build_segments(elf: &Elf) -> Vec<SegmentInfo> {
elf.program_headers
.iter()
.map(|phdr| {
let name = segment_type_name(phdr.p_type);
let permissions = SectionPermissions {
read: (phdr.p_flags & PF_R) != 0,
write: (phdr.p_flags & PF_W) != 0,
execute: (phdr.p_flags & PF_X) != 0,
};
SegmentInfo {
name,
virtual_address: phdr.p_vaddr,
virtual_size: phdr.p_memsz,
file_offset: phdr.p_offset,
file_size: phdr.p_filesz,
permissions,
}
})
.collect()
}
fn build_elf_info(elf: &Elf) -> ElfInfo {
let os_abi =
os_abi_name(elf.header.e_ident[EI_OSABI]);
let elf_type = elf_type_name(elf.header.e_type);
let interpreter =
elf.interpreter.map(|s| s.to_string());
let gnu_relro = elf
.program_headers
.iter()
.any(|ph| ph.p_type == PT_GNU_RELRO);
let stack_executable = elf
.program_headers
.iter()
.find(|ph| ph.p_type == PT_GNU_STACK)
.is_some_and(|ph| (ph.p_flags & PF_X) != 0);
let mut bind_now = false;
if let Some(dynamic) = &elf.dynamic {
for dyn_entry in &dynamic.dyns {
let tag = dyn_entry.d_tag as u64;
if tag == DT_BIND_NOW {
bind_now = true;
}
if tag == DT_FLAGS
&& (dyn_entry.d_val & DF_BIND_NOW) != 0
{
bind_now = true;
}
}
}
let needed_libraries = elf
.libraries
.iter()
.map(|s| s.to_string())
.collect();
ElfInfo {
os_abi,
elf_type,
interpreter,
gnu_relro,
bind_now,
stack_executable,
needed_libraries,
}
}
fn collect_function_hints(
elf: &Elf,
entry_point: u64,
) -> Vec<u64> {
let mut hints: Vec<u64> = elf
.syms
.iter()
.chain(elf.dynsyms.iter())
.filter(|sym| {
sym.st_type() == STT_FUNC
&& sym.st_value != 0
&& sym.st_value != entry_point
})
.map(|sym| sym.st_value)
.collect();
hints.sort_unstable();
hints.dedup();
hints
}

View File

@ -0,0 +1,361 @@
// ©AngelaMos | 2026
// macho.rs
use goblin::mach::cputype::{
CPU_TYPE_ARM, CPU_TYPE_ARM64, CPU_TYPE_X86,
CPU_TYPE_X86_64,
};
use goblin::mach::load_command::CommandVariant;
use goblin::mach::{Mach, MachO};
use super::{
compute_section_hash, detect_common_anomalies,
FormatResult, MachOInfo, SectionInfo, SegmentInfo,
};
use crate::error::EngineError;
use crate::types::{
Architecture, BinaryFormat, Endianness, SectionPermissions,
};
const MH_OBJECT: u32 = 1;
const MH_EXECUTE: u32 = 2;
const MH_DYLIB: u32 = 6;
const MH_BUNDLE: u32 = 8;
const MH_DSYM: u32 = 10;
const MH_KEXT_BUNDLE: u32 = 11;
const VM_PROT_READ: u32 = 0x01;
const VM_PROT_WRITE: u32 = 0x02;
const VM_PROT_EXECUTE: u32 = 0x04;
pub fn parse_macho(
mach: &Mach,
data: &[u8],
) -> Result<FormatResult, EngineError> {
match mach {
Mach::Binary(macho) => {
parse_single_macho(macho, data, false)
}
Mach::Fat(fat) => {
for arch in fat.iter_arches() {
let arch = arch.map_err(|e| {
EngineError::InvalidBinary {
reason: e.to_string(),
}
})?;
let offset = arch.offset as usize;
let size = arch.size as usize;
let end = offset.saturating_add(size);
if end <= data.len() {
let macho = MachO::parse(data, offset)
.map_err(|e| {
EngineError::InvalidBinary {
reason: e.to_string(),
}
})?;
return parse_single_macho(
&macho, data, true,
);
}
}
Err(EngineError::InvalidBinary {
reason: "no valid architecture in \
universal binary"
.into(),
})
}
}
}
fn parse_single_macho(
macho: &MachO,
data: &[u8],
is_universal: bool,
) -> Result<FormatResult, EngineError> {
let architecture =
map_architecture(macho.header.cputype);
let bits = if macho.is_64 { 64 } else { 32 };
let endianness = if macho.little_endian {
Endianness::Little
} else {
Endianness::Big
};
let entry_point = macho.entry;
let symbols: Vec<_> =
macho.symbols().flatten().collect();
let is_stripped = symbols.is_empty();
let has_debug_info = macho.segments.iter().any(|seg| {
seg.name().is_ok_and(|n| n == "__DWARF")
});
let is_pie = macho.header.flags & 0x0020_0000 != 0;
let sections = build_sections(macho, data);
let segments = build_segments(macho);
let anomalies = detect_common_anomalies(
&sections,
entry_point,
is_stripped,
);
let macho_info =
build_macho_info(macho, is_universal);
let function_hints: Vec<u64> = macho
.symbols()
.flatten()
.filter(|(_, nlist)| {
!nlist.is_stab()
&& nlist.n_type & 0x0e == 0x0e
&& nlist.n_value != 0
&& nlist.n_value != entry_point
})
.map(|(_, nlist)| nlist.n_value)
.collect();
Ok(FormatResult {
format: BinaryFormat::MachO,
architecture,
bits,
endianness,
entry_point,
is_stripped,
is_pie,
has_debug_info,
sections,
segments,
anomalies,
pe_info: None,
elf_info: None,
macho_info: Some(macho_info),
function_hints,
})
}
fn map_architecture(cputype: u32) -> Architecture {
match cputype {
CPU_TYPE_X86 => Architecture::X86,
CPU_TYPE_X86_64 => Architecture::X86_64,
CPU_TYPE_ARM => Architecture::Arm,
CPU_TYPE_ARM64 => Architecture::Aarch64,
other => {
Architecture::Other(format!(
"mach-cpu-{other:#x}"
))
}
}
}
fn file_type_name(filetype: u32) -> String {
match filetype {
MH_OBJECT => "Object".into(),
MH_EXECUTE => "Execute".into(),
MH_DYLIB => "Dylib".into(),
MH_BUNDLE => "Bundle".into(),
MH_DSYM => "Dsym".into(),
MH_KEXT_BUNDLE => "Kext".into(),
other => format!("Unknown({other})"),
}
}
fn cpu_subtype_name(
cputype: u32,
cpusubtype: u32,
) -> String {
let subtype = cpusubtype & 0x00FF_FFFF;
match cputype {
CPU_TYPE_X86 | CPU_TYPE_X86_64 => {
match subtype {
3 => "ALL".into(),
4 => "486".into(),
8 => "PENTIUM_3".into(),
9 => "PENTIUM_M".into(),
10 => "PENTIUM_4".into(),
11 => "ITANIUM".into(),
12 => "XEON".into(),
_ => format!("{subtype}"),
}
}
CPU_TYPE_ARM => match subtype {
6 => "v6".into(),
9 => "v7".into(),
11 => "v7f".into(),
12 => "v7s".into(),
13 => "v7k".into(),
_ => format!("{subtype}"),
},
CPU_TYPE_ARM64 => match subtype {
0 => "ALL".into(),
1 => "v8".into(),
2 => "E".into(),
_ => format!("{subtype}"),
},
_ => format!("{subtype}"),
}
}
fn build_sections(
macho: &MachO,
data: &[u8],
) -> Vec<SectionInfo> {
let mut sections = Vec::new();
for segment in macho.segments.iter() {
let initprot = segment.initprot;
let seg_permissions = SectionPermissions {
read: (initprot & VM_PROT_READ) != 0,
write: (initprot & VM_PROT_WRITE) != 0,
execute: (initprot & VM_PROT_EXECUTE)
!= 0,
};
for section_result in segment.into_iter() {
let Ok((section, _section_data)) =
section_result
else {
continue;
};
let name = section
.name()
.unwrap_or("???")
.to_string();
let raw_offset = section.offset as u64;
let raw_size = section.size;
let sha256 = compute_section_hash(
data, raw_offset, raw_size,
);
sections.push(SectionInfo {
name,
virtual_address: section.addr,
virtual_size: section.size,
raw_offset,
raw_size,
permissions: seg_permissions.clone(),
sha256,
});
}
}
sections
}
fn build_segments(macho: &MachO) -> Vec<SegmentInfo> {
macho
.segments
.iter()
.map(|seg| {
let name = seg
.name()
.ok()
.map(|n| n.to_string());
let initprot = seg.initprot;
let permissions = SectionPermissions {
read: (initprot & VM_PROT_READ) != 0,
write: (initprot & VM_PROT_WRITE) != 0,
execute: (initprot & VM_PROT_EXECUTE)
!= 0,
};
SegmentInfo {
name,
virtual_address: seg.vmaddr,
virtual_size: seg.vmsize,
file_offset: seg.fileoff,
file_size: seg.filesize,
permissions,
}
})
.collect()
}
fn build_macho_info(
macho: &MachO,
is_universal: bool,
) -> MachOInfo {
let file_type =
file_type_name(macho.header.filetype);
let cpu_subtype = cpu_subtype_name(
macho.header.cputype,
macho.header.cpusubtype,
);
let mut has_code_signature = false;
let mut has_function_starts = false;
let mut min_os_version: Option<String> = None;
let mut sdk_version: Option<String> = None;
for lc in &macho.load_commands {
match &lc.command {
CommandVariant::CodeSignature(_) => {
has_code_signature = true;
}
CommandVariant::FunctionStarts(_) => {
has_function_starts = true;
}
CommandVariant::VersionMinMacosx(ver) => {
min_os_version = Some(format!(
"{}.{}.{}",
ver.version >> 16,
(ver.version >> 8) & 0xFF,
ver.version & 0xFF,
));
sdk_version = Some(format!(
"{}.{}.{}",
ver.sdk >> 16,
(ver.sdk >> 8) & 0xFF,
ver.sdk & 0xFF,
));
}
CommandVariant::VersionMinIphoneos(ver) => {
if min_os_version.is_none() {
min_os_version = Some(format!(
"iOS {}.{}.{}",
ver.version >> 16,
(ver.version >> 8) & 0xFF,
ver.version & 0xFF,
));
sdk_version = Some(format!(
"{}.{}.{}",
ver.sdk >> 16,
(ver.sdk >> 8) & 0xFF,
ver.sdk & 0xFF,
));
}
}
CommandVariant::BuildVersion(bv) => {
if min_os_version.is_none() {
min_os_version = Some(format!(
"{}.{}.{}",
bv.minos >> 16,
(bv.minos >> 8) & 0xFF,
bv.minos & 0xFF,
));
sdk_version = Some(format!(
"{}.{}.{}",
bv.sdk >> 16,
(bv.sdk >> 8) & 0xFF,
bv.sdk & 0xFF,
));
}
}
_ => {}
}
}
let dylibs = macho
.libs
.iter()
.filter(|lib| !lib.is_empty())
.map(|lib| lib.to_string())
.collect();
MachOInfo {
file_type,
cpu_subtype,
is_universal,
has_code_signature,
min_os_version,
sdk_version,
dylibs,
has_function_starts,
}
}

View File

@ -0,0 +1,335 @@
// ©AngelaMos | 2026
// mod.rs
mod elf;
mod macho;
mod pe;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::EngineError;
use crate::types::{
Architecture, BinaryFormat, Endianness, SectionPermissions,
};
pub const SUSPICIOUS_SECTION_NAMES: &[(&str, &str)] = &[
("UPX0", "UPX packer"),
("UPX1", "UPX packer"),
("UPX2", "UPX packer"),
(".nsp0", "NSPack"),
(".nsp1", "NSPack"),
(".nsp2", "NSPack"),
(".aspack", "ASPack"),
(".adata", "ASPack"),
(".MPress1", "MPress"),
(".MPress2", "MPress"),
(".themida", "Themida"),
(".vmp0", "VMProtect"),
(".vmp1", "VMProtect"),
(".enigma1", "Enigma"),
(".enigma2", "Enigma"),
];
const VIRTUAL_RAW_RATIO_THRESHOLD: f64 = 10.0;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatResult {
pub format: BinaryFormat,
pub architecture: Architecture,
pub bits: u8,
pub endianness: Endianness,
pub entry_point: u64,
pub is_stripped: bool,
pub is_pie: bool,
pub has_debug_info: bool,
pub sections: Vec<SectionInfo>,
pub segments: Vec<SegmentInfo>,
pub anomalies: Vec<FormatAnomaly>,
pub pe_info: Option<PeInfo>,
pub elf_info: Option<ElfInfo>,
pub macho_info: Option<MachOInfo>,
#[serde(default)]
pub function_hints: Vec<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionInfo {
pub name: String,
pub virtual_address: u64,
pub virtual_size: u64,
pub raw_offset: u64,
pub raw_size: u64,
pub permissions: SectionPermissions,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SegmentInfo {
pub name: Option<String>,
pub virtual_address: u64,
pub virtual_size: u64,
pub file_offset: u64,
pub file_size: u64,
pub permissions: SectionPermissions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FormatAnomaly {
EntryPointOutsideText {
ep: u64,
text_range: (u64, u64),
},
EntryPointInLastSection {
ep: u64,
section: String,
},
EntryPointOutsideSections {
ep: u64,
},
RwxSection {
name: String,
},
EmptySectionName {
index: usize,
},
StrippedBinary,
SuspiciousSectionName {
name: String,
reason: String,
},
ZeroSizeCodeSection {
name: String,
},
VirtualRawSizeMismatch {
name: String,
virtual_size: u64,
raw_size: u64,
ratio: f64,
},
OverlayData {
offset: u64,
size: u64,
},
TlsCallbacksPresent {
count: usize,
},
NoImportTable,
SuspiciousTimestamp {
value: u32,
reason: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeInfo {
pub image_base: u64,
pub subsystem: String,
pub dll_characteristics: PeDllCharacteristics,
pub timestamp: u32,
pub linker_version: String,
pub tls_callback_count: usize,
pub has_overlay: bool,
pub overlay_size: u64,
pub rich_header_present: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeDllCharacteristics {
pub aslr: bool,
pub dep: bool,
pub cfg: bool,
pub no_seh: bool,
pub force_integrity: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElfInfo {
pub os_abi: String,
pub elf_type: String,
pub interpreter: Option<String>,
pub gnu_relro: bool,
pub bind_now: bool,
pub stack_executable: bool,
pub needed_libraries: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachOInfo {
pub file_type: String,
pub cpu_subtype: String,
pub is_universal: bool,
pub has_code_signature: bool,
pub min_os_version: Option<String>,
pub sdk_version: Option<String>,
pub dylibs: Vec<String>,
pub has_function_starts: bool,
}
pub fn parse_format(
data: &[u8],
) -> Result<FormatResult, EngineError> {
let object =
goblin::Object::parse(data).map_err(|e| {
EngineError::InvalidBinary {
reason: e.to_string(),
}
})?;
match &object {
goblin::Object::Elf(elf_obj) => {
elf::parse_elf(elf_obj, data)
}
goblin::Object::PE(pe_obj) => {
pe::parse_pe(pe_obj, data)
}
goblin::Object::Mach(mach_obj) => {
macho::parse_macho(mach_obj, data)
}
_ => Err(EngineError::UnsupportedFormat {
format: "unknown".into(),
}),
}
}
fn compute_section_hash(
data: &[u8],
offset: u64,
size: u64,
) -> String {
if size == 0 {
return String::new();
}
let start = offset as usize;
let end = start.saturating_add(size as usize);
if start >= data.len() || end > data.len() {
return String::new();
}
let hash = Sha256::digest(&data[start..end]);
format!("{hash:x}")
}
fn check_suspicious_name(name: &str) -> Option<String> {
for &(suspicious, reason) in SUSPICIOUS_SECTION_NAMES {
if name == suspicious {
return Some(reason.into());
}
}
None
}
fn detect_common_anomalies(
sections: &[SectionInfo],
entry_point: u64,
is_stripped: bool,
) -> Vec<FormatAnomaly> {
let mut anomalies = Vec::new();
let text_section =
sections.iter().find(|s| s.name == ".text");
if let Some(text) = text_section {
let text_end =
text.virtual_address + text.virtual_size;
if entry_point != 0
&& (entry_point < text.virtual_address
|| entry_point >= text_end)
{
anomalies.push(
FormatAnomaly::EntryPointOutsideText {
ep: entry_point,
text_range: (
text.virtual_address,
text_end,
),
},
);
}
}
if let Some(last) = sections.last() {
let last_end =
last.virtual_address + last.virtual_size;
if entry_point >= last.virtual_address
&& entry_point < last_end
{
anomalies.push(
FormatAnomaly::EntryPointInLastSection {
ep: entry_point,
section: last.name.clone(),
},
);
}
}
let ep_in_any = sections.iter().any(|s| {
entry_point >= s.virtual_address
&& entry_point
< s.virtual_address + s.virtual_size
});
if !ep_in_any && entry_point != 0 {
anomalies.push(
FormatAnomaly::EntryPointOutsideSections {
ep: entry_point,
},
);
}
for (idx, section) in sections.iter().enumerate() {
if section.permissions.is_rwx() {
anomalies.push(FormatAnomaly::RwxSection {
name: section.name.clone(),
});
}
if section.name.is_empty() {
anomalies.push(
FormatAnomaly::EmptySectionName {
index: idx,
},
);
}
if let Some(reason) =
check_suspicious_name(&section.name)
{
anomalies.push(
FormatAnomaly::SuspiciousSectionName {
name: section.name.clone(),
reason,
},
);
}
if section.permissions.execute
&& section.virtual_size == 0
{
anomalies.push(
FormatAnomaly::ZeroSizeCodeSection {
name: section.name.clone(),
},
);
}
if section.raw_size > 0 {
let ratio = section.virtual_size as f64
/ section.raw_size as f64;
if ratio > VIRTUAL_RAW_RATIO_THRESHOLD {
anomalies.push(
FormatAnomaly::VirtualRawSizeMismatch {
name: section.name.clone(),
virtual_size: section
.virtual_size,
raw_size: section.raw_size,
ratio,
},
);
}
}
}
if is_stripped {
anomalies.push(FormatAnomaly::StrippedBinary);
}
anomalies
}

View File

@ -0,0 +1,332 @@
// ©AngelaMos | 2026
// pe.rs
use goblin::pe::PE;
use super::{
compute_section_hash, detect_common_anomalies,
FormatAnomaly, FormatResult, PeDllCharacteristics, PeInfo,
SectionInfo, SegmentInfo,
};
use crate::error::EngineError;
use crate::types::{
Architecture, BinaryFormat, Endianness, SectionPermissions,
};
const COFF_MACHINE_I386: u16 = 0x14c;
const COFF_MACHINE_AMD64: u16 = 0x8664;
const COFF_MACHINE_ARM: u16 = 0x1c0;
const COFF_MACHINE_ARMNT: u16 = 0x1c4;
const COFF_MACHINE_ARM64: u16 = 0xaa64;
const IMAGE_SCN_MEM_READ: u32 = 0x4000_0000;
const IMAGE_SCN_MEM_WRITE: u32 = 0x8000_0000;
const IMAGE_SCN_MEM_EXECUTE: u32 = 0x2000_0000;
const IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE: u16 = 0x0040;
const IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY: u16 =
0x0080;
const IMAGE_DLLCHARACTERISTICS_NX_COMPAT: u16 = 0x0100;
const IMAGE_DLLCHARACTERISTICS_NO_SEH: u16 = 0x0400;
const IMAGE_DLLCHARACTERISTICS_GUARD_CF: u16 = 0x4000;
const IMAGE_SUBSYSTEM_UNKNOWN: u16 = 0;
const IMAGE_SUBSYSTEM_NATIVE: u16 = 1;
const IMAGE_SUBSYSTEM_WINDOWS_GUI: u16 = 2;
const IMAGE_SUBSYSTEM_WINDOWS_CUI: u16 = 3;
const IMAGE_SUBSYSTEM_POSIX_CUI: u16 = 7;
const IMAGE_SUBSYSTEM_WINDOWS_CE_GUI: u16 = 9;
const IMAGE_SUBSYSTEM_EFI_APPLICATION: u16 = 10;
const IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER: u16 = 11;
const IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER: u16 = 12;
const IMAGE_SUBSYSTEM_XBOX: u16 = 14;
const PE_TIMESTAMP_MIN_VALID: u32 = 631_152_000;
const PE_TIMESTAMP_MAX_VALID: u32 = 4_102_444_800;
const RICH_SIGNATURE: &[u8] = b"Rich";
pub fn parse_pe(
pe: &PE,
data: &[u8],
) -> Result<FormatResult, EngineError> {
let architecture = map_architecture(
pe.header.coff_header.machine,
);
let bits = if pe.is_64 { 64 } else { 32 };
let endianness = Endianness::Little;
let entry_point = pe.entry as u64;
let is_stripped = pe.debug_data.is_none();
let optional = pe.header.optional_header.as_ref();
let dll_chars = optional.map_or(0, |oh| {
oh.windows_fields.dll_characteristics
});
let is_pie = (dll_chars
& IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE)
!= 0;
let has_debug_info = pe.debug_data.is_some();
let sections = build_sections(pe, data);
let segments = build_segments(pe);
let timestamp =
pe.header.coff_header.time_date_stamp;
let image_base = optional
.map_or(0, |oh| oh.windows_fields.image_base);
let subsystem_raw = optional
.map_or(0, |oh| oh.windows_fields.subsystem);
let linker_version = optional.map_or_else(
|| "0.0".into(),
|oh| {
format!(
"{}.{}",
oh.standard_fields.major_linker_version,
oh.standard_fields.minor_linker_version,
)
},
);
let has_tls = pe.tls_data.is_some();
let tls_callback_count = usize::from(has_tls);
let max_section_end = pe
.sections
.iter()
.map(|s| {
s.pointer_to_raw_data as u64
+ s.size_of_raw_data as u64
})
.max()
.unwrap_or(0);
let file_size = data.len() as u64;
let has_overlay =
max_section_end > 0 && max_section_end < file_size;
let overlay_size = if has_overlay {
file_size - max_section_end
} else {
0
};
let pe_offset =
pe.header.dos_header.pe_pointer as usize;
let rich_header_present = detect_rich_header(
data,
pe_offset,
);
let pe_info = PeInfo {
image_base,
subsystem: subsystem_name(subsystem_raw),
dll_characteristics: PeDllCharacteristics {
aslr: (dll_chars
& IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE)
!= 0,
dep: (dll_chars
& IMAGE_DLLCHARACTERISTICS_NX_COMPAT)
!= 0,
cfg: (dll_chars
& IMAGE_DLLCHARACTERISTICS_GUARD_CF)
!= 0,
no_seh: (dll_chars
& IMAGE_DLLCHARACTERISTICS_NO_SEH)
!= 0,
force_integrity: (dll_chars
& IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY)
!= 0,
},
timestamp,
linker_version,
tls_callback_count,
has_overlay,
overlay_size,
rich_header_present,
};
let mut anomalies = detect_common_anomalies(
&sections,
entry_point,
is_stripped,
);
detect_pe_anomalies(
&mut anomalies,
pe,
timestamp,
has_tls,
has_overlay,
max_section_end,
file_size,
);
let function_hints: Vec<u64> = pe
.exports
.iter()
.filter(|e| e.rva != 0)
.map(|e| image_base + e.rva as u64)
.filter(|&addr| addr != entry_point)
.collect();
Ok(FormatResult {
format: BinaryFormat::Pe,
architecture,
bits,
endianness,
entry_point,
is_stripped,
is_pie,
has_debug_info,
sections,
segments,
anomalies,
pe_info: Some(pe_info),
elf_info: None,
macho_info: None,
function_hints,
})
}
fn map_architecture(machine: u16) -> Architecture {
match machine {
COFF_MACHINE_I386 => Architecture::X86,
COFF_MACHINE_AMD64 => Architecture::X86_64,
COFF_MACHINE_ARM | COFF_MACHINE_ARMNT => {
Architecture::Arm
}
COFF_MACHINE_ARM64 => Architecture::Aarch64,
other => {
Architecture::Other(format!(
"pe-machine-{other:#x}"
))
}
}
}
fn subsystem_name(subsystem: u16) -> String {
match subsystem {
IMAGE_SUBSYSTEM_UNKNOWN => "Unknown".into(),
IMAGE_SUBSYSTEM_NATIVE => "Native".into(),
IMAGE_SUBSYSTEM_WINDOWS_GUI => "GUI".into(),
IMAGE_SUBSYSTEM_WINDOWS_CUI => "Console".into(),
IMAGE_SUBSYSTEM_POSIX_CUI => "POSIX".into(),
IMAGE_SUBSYSTEM_WINDOWS_CE_GUI => {
"Windows CE".into()
}
IMAGE_SUBSYSTEM_EFI_APPLICATION => {
"EFI Application".into()
}
IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER => {
"EFI Boot Service Driver".into()
}
IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER => {
"EFI Runtime Driver".into()
}
IMAGE_SUBSYSTEM_XBOX => "Xbox".into(),
other => format!("Unknown({other})"),
}
}
fn build_sections(
pe: &PE,
data: &[u8],
) -> Vec<SectionInfo> {
pe.sections
.iter()
.map(|section| {
let name = section
.name()
.unwrap_or_default()
.to_string();
let raw_offset =
section.pointer_to_raw_data as u64;
let raw_size =
section.size_of_raw_data as u64;
let chars = section.characteristics;
let permissions = SectionPermissions {
read: (chars & IMAGE_SCN_MEM_READ) != 0,
write: (chars & IMAGE_SCN_MEM_WRITE) != 0,
execute: (chars & IMAGE_SCN_MEM_EXECUTE)
!= 0,
};
let sha256 = compute_section_hash(
data, raw_offset, raw_size,
);
SectionInfo {
name,
virtual_address: section.virtual_address
as u64,
virtual_size: section.virtual_size as u64,
raw_offset,
raw_size,
permissions,
sha256,
}
})
.collect()
}
fn build_segments(_pe: &PE) -> Vec<SegmentInfo> {
Vec::new()
}
fn detect_rich_header(
data: &[u8],
pe_offset: usize,
) -> bool {
let end = pe_offset.min(data.len());
data[..end]
.windows(RICH_SIGNATURE.len())
.any(|w| w == RICH_SIGNATURE)
}
fn detect_pe_anomalies(
anomalies: &mut Vec<FormatAnomaly>,
pe: &PE,
timestamp: u32,
has_tls: bool,
has_overlay: bool,
overlay_offset: u64,
file_size: u64,
) {
if timestamp == 0 {
anomalies.push(
FormatAnomaly::SuspiciousTimestamp {
value: timestamp,
reason: "zeroed timestamp".into(),
},
);
} else if timestamp < PE_TIMESTAMP_MIN_VALID {
anomalies.push(
FormatAnomaly::SuspiciousTimestamp {
value: timestamp,
reason: "timestamp before 1990".into(),
},
);
} else if timestamp > PE_TIMESTAMP_MAX_VALID {
anomalies.push(
FormatAnomaly::SuspiciousTimestamp {
value: timestamp,
reason: "timestamp after 2100".into(),
},
);
}
if has_tls {
anomalies.push(
FormatAnomaly::TlsCallbacksPresent {
count: 1,
},
);
}
if pe.imports.is_empty() {
anomalies.push(FormatAnomaly::NoImportTable);
}
if has_overlay {
anomalies.push(FormatAnomaly::OverlayData {
offset: overlay_offset,
size: file_size - overlay_offset,
});
}
}

View File

@ -0,0 +1,76 @@
// ©AngelaMos | 2026
// lib.rs
pub mod context;
pub mod error;
pub mod formats;
pub mod pass;
pub mod passes;
pub mod types;
pub mod yara;
use std::sync::Arc;
use sha2::{Digest, Sha256};
use context::{AnalysisContext, BinarySource};
use error::EngineError;
use pass::{PassManager, PassReport};
use passes::disasm::DisasmPass;
use passes::entropy::EntropyPass;
use passes::format::FormatPass;
use passes::imports::ImportPass;
use passes::strings::StringPass;
use passes::threat::ThreatPass;
pub struct AnalysisEngine {
pass_manager: PassManager,
}
impl AnalysisEngine {
pub fn new() -> Result<Self, EngineError> {
let passes: Vec<Box<dyn pass::AnalysisPass>> =
vec![
Box::new(FormatPass),
Box::new(ImportPass),
Box::new(StringPass),
Box::new(EntropyPass),
Box::new(DisasmPass),
Box::new(ThreatPass),
];
let pass_manager = PassManager::new(passes);
Ok(Self { pass_manager })
}
pub fn analyze(
&self,
data: &[u8],
file_name: &str,
) -> (AnalysisContext, PassReport) {
let sha256 = compute_sha256(data);
let file_size = data.len() as u64;
let mut ctx = AnalysisContext::new(
BinarySource::Buffered(Arc::from(
data.to_vec(),
)),
sha256,
file_name.to_string(),
file_size,
);
let report =
self.pass_manager.run_all(&mut ctx);
(ctx, report)
}
}
pub fn sha256_hex(data: &[u8]) -> String {
compute_sha256(data)
}
fn compute_sha256(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}

View File

@ -0,0 +1,386 @@
// ©AngelaMos | 2026
// pass.rs
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use crate::context::AnalysisContext;
use crate::error::EngineError;
mod private {
pub trait Sealed {}
}
pub trait AnalysisPass: private::Sealed + Send + Sync {
fn name(&self) -> &'static str;
fn dependencies(&self) -> &[&'static str];
fn run(
&self,
ctx: &mut AnalysisContext,
) -> Result<(), EngineError>;
}
#[derive(Debug, Clone)]
pub struct PassOutcome {
pub name: &'static str,
pub success: bool,
pub duration_ms: u64,
pub error_message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PassReport {
pub outcomes: Vec<PassOutcome>,
}
impl PassReport {
pub fn all_succeeded(&self) -> bool {
self.outcomes.iter().all(|o| o.success)
}
pub fn failed_passes(&self) -> Vec<&PassOutcome> {
self.outcomes
.iter()
.filter(|o| !o.success)
.collect()
}
}
pub struct PassManager {
passes: Vec<Box<dyn AnalysisPass>>,
order: Vec<usize>,
}
impl PassManager {
pub fn new(
passes: Vec<Box<dyn AnalysisPass>>,
) -> Self {
let order = topological_order(&passes);
Self { passes, order }
}
pub fn run_all(
&self,
ctx: &mut AnalysisContext,
) -> PassReport {
let mut outcomes = Vec::with_capacity(self.passes.len());
for &idx in &self.order {
let pass = &self.passes[idx];
let start = Instant::now();
let result = pass.run(ctx);
let duration_ms =
start.elapsed().as_millis() as u64;
let outcome = match result {
Ok(()) => {
tracing::info!(
pass = pass.name(),
duration_ms,
"pass completed"
);
PassOutcome {
name: pass.name(),
success: true,
duration_ms,
error_message: None,
}
}
Err(e) => {
tracing::error!(
pass = pass.name(),
error = %e,
duration_ms,
"pass failed"
);
PassOutcome {
name: pass.name(),
success: false,
duration_ms,
error_message: Some(e.to_string()),
}
}
};
outcomes.push(outcome);
}
PassReport { outcomes }
}
}
fn topological_order(
passes: &[Box<dyn AnalysisPass>],
) -> Vec<usize> {
let name_to_idx: HashMap<&str, usize> = passes
.iter()
.enumerate()
.map(|(i, p)| (p.name(), i))
.collect();
let n = passes.len();
let mut in_degree = vec![0usize; n];
let mut adjacency: Vec<Vec<usize>> = vec![vec![]; n];
for (idx, pass) in passes.iter().enumerate() {
for dep_name in pass.dependencies() {
if let Some(&dep_idx) = name_to_idx.get(dep_name)
{
adjacency[dep_idx].push(idx);
in_degree[idx] += 1;
}
}
}
let mut queue: VecDeque<usize> = in_degree
.iter()
.enumerate()
.filter(|&(_, deg)| *deg == 0)
.map(|(i, _)| i)
.collect();
let mut order = Vec::with_capacity(n);
while let Some(node) = queue.pop_front() {
order.push(node);
for &neighbor in &adjacency[node] {
in_degree[neighbor] -= 1;
if in_degree[neighbor] == 0 {
queue.push_back(neighbor);
}
}
}
assert_eq!(
order.len(),
n,
"cycle detected in pass dependencies — this is a programmer error"
);
order
}
pub(crate) use private::Sealed;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
struct MockPass {
name: &'static str,
deps: Vec<&'static str>,
log: Arc<Mutex<Vec<&'static str>>>,
should_fail: bool,
}
impl Sealed for MockPass {}
impl AnalysisPass for MockPass {
fn name(&self) -> &'static str {
self.name
}
fn dependencies(&self) -> &[&'static str] {
&self.deps
}
fn run(
&self,
_ctx: &mut AnalysisContext,
) -> Result<(), EngineError> {
self.log.lock().unwrap().push(self.name);
if self.should_fail {
return Err(EngineError::PassFailed {
pass: self.name,
source: "mock failure".into(),
});
}
Ok(())
}
}
fn make_ctx() -> AnalysisContext {
AnalysisContext::new(
crate::context::BinarySource::Buffered(
Arc::from(vec![0u8; 4]),
),
"deadbeef".into(),
"test.bin".into(),
4,
)
}
#[test]
fn topological_sort_respects_dependencies() {
let log = Arc::new(Mutex::new(Vec::new()));
let passes: Vec<Box<dyn AnalysisPass>> = vec![
Box::new(MockPass {
name: "c",
deps: vec!["b"],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "a",
deps: vec![],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "b",
deps: vec!["a"],
log: Arc::clone(&log),
should_fail: false,
}),
];
let manager = PassManager::new(passes);
let mut ctx = make_ctx();
let report = manager.run_all(&mut ctx);
let execution_order = log.lock().unwrap().clone();
assert_eq!(execution_order, vec!["a", "b", "c"]);
assert!(report.all_succeeded());
assert_eq!(report.outcomes.len(), 3);
}
#[test]
fn continues_on_failure() {
let log = Arc::new(Mutex::new(Vec::new()));
let passes: Vec<Box<dyn AnalysisPass>> = vec![
Box::new(MockPass {
name: "first",
deps: vec![],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "second",
deps: vec![],
log: Arc::clone(&log),
should_fail: true,
}),
Box::new(MockPass {
name: "third",
deps: vec![],
log: Arc::clone(&log),
should_fail: false,
}),
];
let manager = PassManager::new(passes);
let mut ctx = make_ctx();
let report = manager.run_all(&mut ctx);
let execution_order = log.lock().unwrap().clone();
assert_eq!(
execution_order,
vec!["first", "second", "third"]
);
assert!(!report.all_succeeded());
assert_eq!(report.failed_passes().len(), 1);
assert_eq!(
report.failed_passes()[0].name,
"second"
);
}
#[test]
fn diamond_dependency_ordering() {
let log = Arc::new(Mutex::new(Vec::new()));
let passes: Vec<Box<dyn AnalysisPass>> = vec![
Box::new(MockPass {
name: "score",
deps: vec!["imports", "entropy"],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "entropy",
deps: vec!["format"],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "format",
deps: vec![],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "imports",
deps: vec!["format"],
log: Arc::clone(&log),
should_fail: false,
}),
];
let manager = PassManager::new(passes);
let mut ctx = make_ctx();
manager.run_all(&mut ctx);
let order = log.lock().unwrap().clone();
let format_pos =
order.iter().position(|&n| n == "format").unwrap();
let imports_pos =
order.iter().position(|&n| n == "imports").unwrap();
let entropy_pos =
order.iter().position(|&n| n == "entropy").unwrap();
let score_pos =
order.iter().position(|&n| n == "score").unwrap();
assert!(format_pos < imports_pos);
assert!(format_pos < entropy_pos);
assert!(imports_pos < score_pos);
assert!(entropy_pos < score_pos);
}
#[test]
#[should_panic(expected = "cycle detected")]
fn detects_cycle() {
let log = Arc::new(Mutex::new(Vec::new()));
let passes: Vec<Box<dyn AnalysisPass>> = vec![
Box::new(MockPass {
name: "a",
deps: vec!["b"],
log: Arc::clone(&log),
should_fail: false,
}),
Box::new(MockPass {
name: "b",
deps: vec!["a"],
log: Arc::clone(&log),
should_fail: false,
}),
];
let _manager = PassManager::new(passes);
}
#[test]
fn reports_duration() {
let log = Arc::new(Mutex::new(Vec::new()));
let passes: Vec<Box<dyn AnalysisPass>> = vec![
Box::new(MockPass {
name: "fast",
deps: vec![],
log: Arc::clone(&log),
should_fail: false,
}),
];
let manager = PassManager::new(passes);
let mut ctx = make_ctx();
let report = manager.run_all(&mut ctx);
assert_eq!(report.outcomes.len(), 1);
assert_eq!(report.outcomes[0].name, "fast");
assert!(report.outcomes[0].success);
}
}

View File

@ -0,0 +1,948 @@
// ©AngelaMos | 2026
// disasm.rs
use std::collections::{
BTreeMap, HashMap, HashSet, VecDeque,
};
use iced_x86::{
Decoder, DecoderOptions, FlowControl, Formatter,
Instruction, IntelFormatter,
};
use serde::{Deserialize, Serialize};
use crate::context::AnalysisContext;
use crate::error::EngineError;
use crate::formats::SectionInfo;
use crate::pass::{AnalysisPass, Sealed};
use crate::types::{
Architecture, CfgEdgeType, FlowControlType,
};
const MAX_FUNCTIONS: usize = 1000;
const MAX_INSTRUCTIONS: usize = 50_000;
const CFG_INSTRUCTION_LIMIT: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisassemblyResult {
pub functions: Vec<FunctionInfo>,
pub total_instructions: usize,
pub total_functions: usize,
pub architecture_bits: u8,
pub entry_function_address: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionInfo {
pub address: u64,
pub name: Option<String>,
pub size: u64,
pub instruction_count: usize,
pub basic_blocks: Vec<BasicBlockInfo>,
pub is_entry_point: bool,
pub cfg: FunctionCfg,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BasicBlockInfo {
pub start_address: u64,
pub end_address: u64,
pub instruction_count: usize,
pub instructions: Vec<InstructionInfo>,
pub successors: Vec<u64>,
pub predecessors: Vec<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstructionInfo {
pub address: u64,
pub bytes: Vec<u8>,
pub mnemonic: String,
pub operands: String,
pub size: u8,
pub flow_control: FlowControlType,
}
#[derive(
Debug, Clone, Default, Serialize, Deserialize,
)]
pub struct FunctionCfg {
pub nodes: Vec<CfgNode>,
pub edges: Vec<CfgEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfgNode {
pub id: u64,
pub label: String,
pub instruction_count: usize,
pub instructions_preview: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfgEdge {
pub from: u64,
pub to: u64,
pub edge_type: CfgEdgeType,
}
pub struct DisasmPass;
impl Sealed for DisasmPass {}
impl AnalysisPass for DisasmPass {
fn name(&self) -> &'static str {
"disasm"
}
fn dependencies(&self) -> &[&'static str] {
&["format"]
}
fn run(
&self,
ctx: &mut AnalysisContext,
) -> Result<(), EngineError> {
let format_result = ctx
.format_result
.as_ref()
.ok_or_else(|| EngineError::MissingDependency {
pass: "disasm".into(),
dependency: "format".into(),
})?;
let arch = &format_result.architecture;
let bits = match arch {
Architecture::X86 => 32u32,
Architecture::X86_64 => 64,
_ => {
ctx.disassembly_result =
Some(empty_result(
format_result.bits,
format_result.entry_point,
));
return Ok(());
}
};
let data = ctx.data();
let sections = &format_result.sections;
let entry_point = format_result.entry_point;
let mut seeds = vec![entry_point];
seeds.extend_from_slice(
&format_result.function_hints,
);
let result = disassemble(
data,
sections,
bits,
entry_point,
&seeds,
);
ctx.disassembly_result = Some(result);
Ok(())
}
}
fn empty_result(
bits: u8,
entry_point: u64,
) -> DisassemblyResult {
DisassemblyResult {
functions: Vec::new(),
total_instructions: 0,
total_functions: 0,
architecture_bits: bits,
entry_function_address: entry_point,
}
}
fn disassemble(
data: &[u8],
sections: &[SectionInfo],
bits: u32,
entry_point: u64,
seeds: &[u64],
) -> DisassemblyResult {
let exec_sections: Vec<&SectionInfo> = sections
.iter()
.filter(|s| s.permissions.execute && s.raw_size > 0)
.collect();
let mut functions = Vec::new();
let mut visited_functions = HashSet::new();
let mut total_instructions = 0;
let mut function_queue: VecDeque<u64> =
seeds.iter().copied().collect();
while let Some(func_addr) = function_queue.pop_front()
{
if functions.len() >= MAX_FUNCTIONS
|| total_instructions >= MAX_INSTRUCTIONS
{
break;
}
if !visited_functions.insert(func_addr) {
continue;
}
if vaddr_to_offset(sections, func_addr).is_none()
{
continue;
}
let (func_info, discovered_calls) =
disassemble_function(
data,
sections,
&exec_sections,
bits,
func_addr,
func_addr == entry_point,
MAX_INSTRUCTIONS - total_instructions,
);
total_instructions += func_info.instruction_count;
functions.push(func_info);
for call_target in discovered_calls {
if !visited_functions.contains(&call_target) {
function_queue.push_back(call_target);
}
}
}
let total_functions = functions.len();
DisassemblyResult {
functions,
total_instructions,
total_functions,
architecture_bits: bits as u8,
entry_function_address: entry_point,
}
}
fn disassemble_function(
data: &[u8],
all_sections: &[SectionInfo],
exec_sections: &[&SectionInfo],
bits: u32,
func_addr: u64,
is_entry_point: bool,
instruction_budget: usize,
) -> (FunctionInfo, Vec<u64>) {
let mut decoded: BTreeMap<u64, DecodedInstruction> =
BTreeMap::new();
let mut block_leaders: HashSet<u64> = HashSet::new();
let mut worklist: VecDeque<u64> = VecDeque::new();
let mut visited: HashSet<u64> = HashSet::new();
let mut discovered_calls: Vec<u64> = Vec::new();
let mut formatter = IntelFormatter::new();
block_leaders.insert(func_addr);
worklist.push_back(func_addr);
while let Some(addr) = worklist.pop_front() {
if !visited.insert(addr) {
continue;
}
if decoded.len() >= instruction_budget {
break;
}
let offset = match vaddr_to_offset(
all_sections,
addr,
) {
Some(o) => o as usize,
None => continue,
};
if !is_in_exec_section(exec_sections, addr) {
continue;
}
let remaining = data.len().saturating_sub(offset);
if remaining == 0 {
continue;
}
let slice = &data[offset..];
let mut decoder = Decoder::with_ip(
bits,
slice,
addr,
DecoderOptions::NONE,
);
let mut instr = Instruction::default();
while decoder.can_decode()
&& decoded.len() < instruction_budget
{
decoder.decode_out(&mut instr);
let ip = instr.ip();
if ip != addr && visited.contains(&ip) {
break;
}
if ip != addr
&& block_leaders.contains(&ip)
{
break;
}
let fc = instr.flow_control();
let mnemonic = format!("{:?}", instr.mnemonic())
.to_ascii_lowercase();
let mut operands_str = String::new();
formatter
.format(&instr, &mut operands_str);
let operands = operands_str
.split_once(' ')
.map_or(String::new(), |(_, ops)| {
ops.to_string()
});
let instr_bytes = &data
[offset + (ip - addr) as usize
..offset
+ (ip - addr) as usize
+ instr.len()];
let flow_type = map_flow_control(fc);
decoded.insert(
ip,
DecodedInstruction {
info: InstructionInfo {
address: ip,
bytes: instr_bytes.to_vec(),
mnemonic,
operands,
size: instr.len() as u8,
flow_control: flow_type,
},
next_ip: instr.next_ip(),
branch_target: None,
fallthrough: None,
},
);
match fc {
FlowControl::ConditionalBranch => {
let target =
instr.near_branch_target();
let fall = instr.next_ip();
if let Some(di) =
decoded.get_mut(&ip)
{
di.branch_target = Some(target);
di.fallthrough = Some(fall);
}
block_leaders.insert(target);
block_leaders.insert(fall);
worklist.push_back(target);
worklist.push_back(fall);
break;
}
FlowControl::UnconditionalBranch => {
let target =
instr.near_branch_target();
if let Some(di) =
decoded.get_mut(&ip)
{
di.branch_target = Some(target);
}
block_leaders.insert(target);
worklist.push_back(target);
break;
}
FlowControl::Return
| FlowControl::Interrupt
| FlowControl::IndirectBranch
| FlowControl::Exception => {
break;
}
FlowControl::Call => {
let target =
instr.near_branch_target();
if target != 0 {
discovered_calls.push(target);
}
}
FlowControl::IndirectCall => {}
FlowControl::Next
| FlowControl::XbeginXabortXend => {}
}
}
}
let basic_blocks = build_basic_blocks(
&decoded,
&block_leaders,
);
let instruction_count: usize = basic_blocks
.iter()
.map(|bb| bb.instruction_count)
.sum();
let size = if let (Some(first), Some(last)) = (
decoded.keys().next(),
decoded.keys().next_back(),
) {
if let Some(last_instr) = decoded.get(last) {
last_instr.info.address
+ last_instr.info.size as u64
- first
} else {
0
}
} else {
0
};
let cfg = if instruction_count <= CFG_INSTRUCTION_LIMIT
{
build_cfg(&basic_blocks)
} else {
FunctionCfg::default()
};
let func = FunctionInfo {
address: func_addr,
name: None,
size,
instruction_count,
basic_blocks,
is_entry_point,
cfg,
};
(func, discovered_calls)
}
struct DecodedInstruction {
info: InstructionInfo,
next_ip: u64,
branch_target: Option<u64>,
fallthrough: Option<u64>,
}
fn build_basic_blocks(
decoded: &BTreeMap<u64, DecodedInstruction>,
leaders: &HashSet<u64>,
) -> Vec<BasicBlockInfo> {
if decoded.is_empty() {
return Vec::new();
}
let mut blocks: Vec<BasicBlockInfo> = Vec::new();
let mut current_instrs: Vec<InstructionInfo> =
Vec::new();
let mut block_start: Option<u64> = None;
for (&addr, di) in decoded {
if leaders.contains(&addr)
&& !current_instrs.is_empty()
{
let bb = finalize_block(
&current_instrs,
block_start.unwrap_or(addr),
decoded,
leaders,
);
blocks.push(bb);
current_instrs.clear();
block_start = None;
}
if block_start.is_none() {
block_start = Some(addr);
}
current_instrs.push(di.info.clone());
let is_terminator = matches!(
di.info.flow_control,
FlowControlType::Branch
| FlowControlType::ConditionalBranch
| FlowControlType::Return
| FlowControlType::Interrupt
);
if is_terminator {
let bb = finalize_block(
&current_instrs,
block_start.unwrap_or(addr),
decoded,
leaders,
);
blocks.push(bb);
current_instrs.clear();
block_start = None;
}
}
if !current_instrs.is_empty() {
if let Some(start) = block_start {
let bb = finalize_block(
&current_instrs,
start,
decoded,
leaders,
);
blocks.push(bb);
}
}
let block_starts: HashSet<u64> =
blocks.iter().map(|b| b.start_address).collect();
for block in &mut blocks {
block
.successors
.retain(|s| block_starts.contains(s));
}
let predecessor_map: HashMap<u64, Vec<u64>> = {
let mut map: HashMap<u64, Vec<u64>> =
HashMap::new();
for block in &blocks {
for &succ in &block.successors {
map.entry(succ)
.or_default()
.push(block.start_address);
}
}
map
};
for block in &mut blocks {
block.predecessors = predecessor_map
.get(&block.start_address)
.cloned()
.unwrap_or_default();
}
blocks
}
fn finalize_block(
instructions: &[InstructionInfo],
start: u64,
decoded: &BTreeMap<u64, DecodedInstruction>,
leaders: &HashSet<u64>,
) -> BasicBlockInfo {
let last = instructions.last().unwrap();
let end_address =
last.address + last.size as u64 - 1;
let mut successors = Vec::new();
let last_addr = last.address;
if let Some(di) = decoded.get(&last_addr) {
if let Some(target) = di.branch_target {
successors.push(target);
}
if let Some(fall) = di.fallthrough {
successors.push(fall);
} else if !matches!(
di.info.flow_control,
FlowControlType::Branch
| FlowControlType::Return
| FlowControlType::Interrupt
) {
let next = di.next_ip;
if leaders.contains(&next)
|| decoded.contains_key(&next)
{
successors.push(next);
}
}
}
BasicBlockInfo {
start_address: start,
end_address,
instruction_count: instructions.len(),
instructions: instructions.to_vec(),
successors,
predecessors: Vec::new(),
}
}
fn build_cfg(
blocks: &[BasicBlockInfo],
) -> FunctionCfg {
let mut nodes = Vec::new();
let mut edges = Vec::new();
for block in blocks {
let preview = if block.instructions.is_empty() {
String::new()
} else if block.instructions.len() == 1 {
block.instructions[0].mnemonic.clone()
} else {
format!(
"{} ... {}",
block.instructions[0].mnemonic,
block.instructions.last().unwrap().mnemonic
)
};
nodes.push(CfgNode {
id: block.start_address,
label: format!(
"0x{:x}",
block.start_address
),
instruction_count: block.instruction_count,
instructions_preview: preview,
});
let last_instr = block.instructions.last();
for &succ in &block.successors {
let edge_type =
if let Some(last) = last_instr {
match last.flow_control {
FlowControlType::ConditionalBranch => {
if succ
== block
.successors
.first()
.copied()
.unwrap_or(0)
{
CfgEdgeType::ConditionalTrue
} else {
CfgEdgeType::ConditionalFalse
}
}
FlowControlType::Branch => {
CfgEdgeType::Unconditional
}
_ => CfgEdgeType::Fallthrough,
}
} else {
CfgEdgeType::Fallthrough
};
edges.push(CfgEdge {
from: block.start_address,
to: succ,
edge_type,
});
}
}
FunctionCfg { nodes, edges }
}
fn vaddr_to_offset(
sections: &[SectionInfo],
vaddr: u64,
) -> Option<u64> {
sections.iter().find_map(|s| {
if s.raw_size > 0
&& vaddr >= s.virtual_address
&& vaddr
< s.virtual_address + s.virtual_size
{
Some(
s.raw_offset
+ (vaddr - s.virtual_address),
)
} else {
None
}
})
}
fn is_in_exec_section(
exec_sections: &[&SectionInfo],
vaddr: u64,
) -> bool {
exec_sections.iter().any(|s| {
vaddr >= s.virtual_address
&& vaddr
< s.virtual_address + s.virtual_size
})
}
fn map_flow_control(
fc: FlowControl,
) -> FlowControlType {
match fc {
FlowControl::Next
| FlowControl::XbeginXabortXend => {
FlowControlType::Next
}
FlowControl::UnconditionalBranch
| FlowControl::IndirectBranch => {
FlowControlType::Branch
}
FlowControl::ConditionalBranch => {
FlowControlType::ConditionalBranch
}
FlowControl::Call
| FlowControl::IndirectCall => {
FlowControlType::Call
}
FlowControl::Return => FlowControlType::Return,
FlowControl::Interrupt
| FlowControl::Exception => {
FlowControlType::Interrupt
}
}
}
pub fn disassemble_code(
code: &[u8],
base_addr: u64,
bits: u32,
) -> Vec<InstructionInfo> {
let mut decoder = Decoder::with_ip(
bits,
code,
base_addr,
DecoderOptions::NONE,
);
let mut formatter = IntelFormatter::new();
let mut instr = Instruction::default();
let mut result = Vec::new();
while decoder.can_decode() {
decoder.decode_out(&mut instr);
let mnemonic = format!(
"{:?}",
instr.mnemonic()
)
.to_ascii_lowercase();
let mut full = String::new();
formatter.format(&instr, &mut full);
let operands = full
.split_once(' ')
.map_or(String::new(), |(_, ops)| {
ops.to_string()
});
let start =
(instr.ip() - base_addr) as usize;
let bytes =
code[start..start + instr.len()].to_vec();
result.push(InstructionInfo {
address: instr.ip(),
bytes,
mnemonic,
operands,
size: instr.len() as u8,
flow_control: map_flow_control(
instr.flow_control(),
),
});
}
result
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::context::BinarySource;
use crate::types::SectionPermissions;
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture {path}: {e}")
})
}
fn make_ctx(data: Vec<u8>) -> AnalysisContext {
let size = data.len() as u64;
AnalysisContext::new(
BinarySource::Buffered(Arc::from(data)),
"deadbeef".into(),
"test.bin".into(),
size,
)
}
#[test]
fn disassemble_simple_function() {
let code: &[u8] = &[
0x55, 0x48, 0x89, 0xE5, 0x31, 0xC0,
0x5D, 0xC3,
];
let instrs =
disassemble_code(code, 0x1000, 64);
assert_eq!(instrs.len(), 5);
assert_eq!(instrs[0].mnemonic, "push");
assert_eq!(instrs[4].mnemonic, "ret");
assert_eq!(
instrs[4].flow_control,
FlowControlType::Return
);
}
#[test]
fn basic_block_split_on_branch() {
let code: &[u8] = &[
0x31, 0xC0, 0x85, 0xC0, 0x74, 0x02,
0x31, 0xC9, 0xC3,
];
let sections = vec![SectionInfo {
name: ".text".into(),
virtual_address: 0x1000,
virtual_size: code.len() as u64,
raw_offset: 0,
raw_size: code.len() as u64,
permissions: SectionPermissions {
read: true,
write: false,
execute: true,
},
sha256: String::new(),
}];
let result = disassemble(
code,
&sections,
64,
0x1000,
&[0x1000],
);
assert!(!result.functions.is_empty());
let func = &result.functions[0];
assert!(
func.basic_blocks.len() >= 2,
"conditional branch should create multiple blocks, got {}",
func.basic_blocks.len()
);
}
#[test]
fn cfg_edges_conditional() {
let code: &[u8] = &[
0x31, 0xC0, 0x85, 0xC0, 0x74, 0x02,
0x31, 0xC9, 0xC3,
];
let sections = vec![SectionInfo {
name: ".text".into(),
virtual_address: 0x1000,
virtual_size: code.len() as u64,
raw_offset: 0,
raw_size: code.len() as u64,
permissions: SectionPermissions {
read: true,
write: false,
execute: true,
},
sha256: String::new(),
}];
let result = disassemble(
code,
&sections,
64,
0x1000,
&[0x1000],
);
let func = &result.functions[0];
assert!(
!func.cfg.edges.is_empty(),
"CFG should have edges"
);
assert!(
!func.cfg.nodes.is_empty(),
"CFG should have nodes"
);
}
#[test]
fn non_x86_returns_empty() {
let data = vec![0u8; 64];
let mut ctx = make_ctx(data);
ctx.format_result =
Some(crate::formats::FormatResult {
format: crate::types::BinaryFormat::Elf,
architecture: Architecture::Aarch64,
bits: 64,
endianness:
crate::types::Endianness::Little,
entry_point: 0x1000,
is_stripped: false,
is_pie: false,
has_debug_info: false,
sections: Vec::new(),
segments: Vec::new(),
anomalies: Vec::new(),
pe_info: None,
elf_info: None,
macho_info: None,
function_hints: Vec::new(),
});
DisasmPass.run(&mut ctx).unwrap();
let result = ctx.disassembly_result.unwrap();
assert!(result.functions.is_empty());
assert_eq!(result.total_instructions, 0);
}
#[test]
fn elf_disassembly() {
let data = load_fixture("hello_elf");
let mut ctx = make_ctx(data);
crate::passes::format::FormatPass
.run(&mut ctx)
.unwrap();
DisasmPass.run(&mut ctx).unwrap();
let result =
ctx.disassembly_result.as_ref().unwrap();
assert!(
result.total_functions > 0,
"should find at least one function"
);
assert!(result.total_instructions > 0);
let entry_func = result.functions.iter().find(
|f| {
f.address
== result.entry_function_address
},
);
assert!(
entry_func.is_some()
|| !result.functions.is_empty(),
"should have disassembled functions"
);
}
#[test]
fn disasm_pass_populates_context() {
let data = load_fixture("hello_elf");
let mut ctx = make_ctx(data);
crate::passes::format::FormatPass
.run(&mut ctx)
.unwrap();
assert!(ctx.disassembly_result.is_none());
DisasmPass.run(&mut ctx).unwrap();
assert!(ctx.disassembly_result.is_some());
}
}

View File

@ -0,0 +1,540 @@
// ©AngelaMos | 2026
// entropy.rs
use serde::{Deserialize, Serialize};
use crate::context::AnalysisContext;
use crate::error::EngineError;
use crate::formats::SectionInfo;
use crate::pass::{AnalysisPass, Sealed};
use crate::types::{
EntropyClassification, EntropyFlag,
};
const PLAINTEXT_MAX: f64 = 3.5;
const NATIVE_CODE_MAX: f64 = 6.0;
const COMPRESSED_MAX: f64 = 7.0;
const PACKED_MAX: f64 = 7.2;
const HIGH_ENTROPY_THRESHOLD: f64 = 7.0;
const VIRTUAL_RAW_RATIO_THRESHOLD: f64 = 10.0;
const BYTE_RANGE: usize = 256;
const STRUCTURAL_INDICATORS_FOR_PACKING: usize = 2;
const PUSHAD_OPCODE: u8 = 0x60;
const PACKER_SECTION_NAMES: &[(&str, &str)] = &[
("UPX0", "UPX"),
("UPX1", "UPX"),
("UPX2", "UPX"),
(".themida", "Themida"),
(".vmp0", "VMProtect"),
(".vmp1", "VMProtect"),
(".vmp2", "VMProtect"),
(".aspack", "ASPack"),
(".adata", "ASPack"),
("PEC2TO", "PECompact"),
("PEC2", "PECompact"),
("pec1", "PECompact"),
(".MPRESS1", "MPRESS"),
(".MPRESS2", "MPRESS"),
(".enigma1", "Enigma"),
(".enigma2", "Enigma"),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntropyResult {
pub overall_entropy: f64,
pub sections: Vec<SectionEntropy>,
pub packing_detected: bool,
pub packer_name: Option<String>,
pub packing_indicators: Vec<PackingIndicator>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionEntropy {
pub name: String,
pub entropy: f64,
pub size: u64,
pub classification: EntropyClassification,
pub virtual_to_raw_ratio: f64,
pub is_anomalous: bool,
pub flags: Vec<EntropyFlag>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackingIndicator {
pub indicator_type: String,
pub description: String,
pub evidence: String,
pub packer_name: Option<String>,
}
pub struct EntropyPass;
impl Sealed for EntropyPass {}
impl AnalysisPass for EntropyPass {
fn name(&self) -> &'static str {
"entropy"
}
fn dependencies(&self) -> &[&'static str] {
&["format"]
}
fn run(
&self,
ctx: &mut AnalysisContext,
) -> Result<(), EngineError> {
let format_result = ctx
.format_result
.as_ref()
.ok_or_else(|| EngineError::MissingDependency {
pass: "entropy".into(),
dependency: "format".into(),
})?;
let data = ctx.data();
let result = analyze_entropy(
data,
&format_result.sections,
format_result.entry_point,
);
ctx.entropy_result = Some(result);
Ok(())
}
}
fn analyze_entropy(
data: &[u8],
sections: &[SectionInfo],
entry_point: u64,
) -> EntropyResult {
let overall_entropy = shannon_entropy(data);
let mut section_entropies = Vec::new();
let mut packing_indicators = Vec::new();
let mut packer_name: Option<String> = None;
let mut structural_count = 0;
for section in sections {
let section_data = read_section_data(
data,
section.raw_offset,
section.raw_size,
);
let entropy = if section_data.is_empty() {
0.0
} else {
shannon_entropy(section_data)
};
let classification = classify_entropy(entropy);
let vr_ratio = if section.raw_size > 0 {
section.virtual_size as f64
/ section.raw_size as f64
} else {
0.0
};
let mut flags = Vec::new();
if entropy > HIGH_ENTROPY_THRESHOLD {
flags.push(EntropyFlag::HighEntropy);
}
if vr_ratio > VIRTUAL_RAW_RATIO_THRESHOLD {
flags.push(EntropyFlag::HighVirtualToRawRatio);
}
if section.raw_size == 0
&& section.virtual_size > 0
{
flags.push(EntropyFlag::EmptyRawData);
}
if section.permissions.is_rwx() {
flags.push(EntropyFlag::Rwx);
}
if let Some(packer) =
detect_packer_by_section(&section.name)
{
flags.push(EntropyFlag::PackerSectionName);
packing_indicators.push(PackingIndicator {
indicator_type: "section_name".into(),
description: format!(
"Section name matches {packer} packer"
),
evidence: section.name.clone(),
packer_name: Some(packer.into()),
});
if packer_name.is_none() {
packer_name = Some(packer.into());
}
}
if section.raw_size == 0
&& section.virtual_size > 0
&& section.permissions.execute
{
structural_count += 1;
packing_indicators.push(PackingIndicator {
indicator_type: "structural".into(),
description:
"Empty raw data with executable \
virtual section"
.into(),
evidence: format!(
"section={} raw=0 virtual={}",
section.name, section.virtual_size
),
packer_name: None,
});
}
if vr_ratio > VIRTUAL_RAW_RATIO_THRESHOLD {
structural_count += 1;
packing_indicators.push(PackingIndicator {
indicator_type: "structural".into(),
description:
"High virtual to raw size ratio"
.into(),
evidence: format!(
"section={} ratio={vr_ratio:.1}",
section.name
),
packer_name: None,
});
}
let is_anomalous = !flags.is_empty();
section_entropies.push(SectionEntropy {
name: section.name.clone(),
entropy,
size: section.raw_size,
classification,
virtual_to_raw_ratio: vr_ratio,
is_anomalous,
flags,
});
}
if let Some(ep_section) = find_ep_section(
sections,
entry_point,
) {
let ep_file_offset = entry_point
.wrapping_sub(ep_section.virtual_address)
.wrapping_add(ep_section.raw_offset);
if let Some(&first_byte) =
data.get(ep_file_offset as usize)
{
if first_byte == PUSHAD_OPCODE {
packing_indicators.push(
PackingIndicator {
indicator_type: "entry_point"
.into(),
description:
"PUSHAD at entry point"
.into(),
evidence: format!(
"byte 0x{PUSHAD_OPCODE:02x} \
at EP offset 0x{ep_file_offset:x}"
),
packer_name: None,
},
);
}
}
}
let packing_detected = packer_name.is_some()
|| structural_count
>= STRUCTURAL_INDICATORS_FOR_PACKING;
EntropyResult {
overall_entropy,
sections: section_entropies,
packing_detected,
packer_name,
packing_indicators,
}
}
fn shannon_entropy(data: &[u8]) -> f64 {
if data.is_empty() {
return 0.0;
}
let mut freq = [0u64; BYTE_RANGE];
for &byte in data {
freq[byte as usize] += 1;
}
let len = data.len() as f64;
freq.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
fn classify_entropy(
entropy: f64,
) -> EntropyClassification {
if entropy < PLAINTEXT_MAX {
EntropyClassification::Plaintext
} else if entropy < NATIVE_CODE_MAX {
EntropyClassification::NativeCode
} else if entropy < COMPRESSED_MAX {
EntropyClassification::Compressed
} else if entropy < PACKED_MAX {
EntropyClassification::Packed
} else {
EntropyClassification::Encrypted
}
}
fn detect_packer_by_section(
name: &str,
) -> Option<&'static str> {
PACKER_SECTION_NAMES
.iter()
.find(|&&(section_name, _)| section_name == name)
.map(|&(_, packer)| packer)
}
fn read_section_data(
data: &[u8],
offset: u64,
size: u64,
) -> &[u8] {
if size == 0 {
return &[];
}
let start = offset as usize;
let end = start.saturating_add(size as usize);
if start >= data.len() || end > data.len() {
return &[];
}
&data[start..end]
}
fn find_ep_section(
sections: &[SectionInfo],
entry_point: u64,
) -> Option<&SectionInfo> {
sections.iter().find(|s| {
entry_point >= s.virtual_address
&& entry_point
< s.virtual_address + s.virtual_size
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::context::BinarySource;
use crate::types::SectionPermissions;
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture {path}: {e}")
})
}
fn make_ctx(data: Vec<u8>) -> AnalysisContext {
let size = data.len() as u64;
AnalysisContext::new(
BinarySource::Buffered(Arc::from(data)),
"deadbeef".into(),
"test.bin".into(),
size,
)
}
#[test]
fn entropy_all_zeros() {
let data = vec![0u8; 1024];
assert!(shannon_entropy(&data) < 0.001);
}
#[test]
fn entropy_uniform_distribution() {
let data: Vec<u8> =
(0..=255u8).cycle().take(1024).collect();
let e = shannon_entropy(&data);
assert!(
(e - 8.0).abs() < 0.01,
"uniform distribution should be ~8.0, got {e}"
);
}
#[test]
fn entropy_empty_data() {
assert!(
shannon_entropy(&[]) < 0.001,
"empty data should have zero entropy"
);
}
#[test]
fn entropy_classification_thresholds() {
assert_eq!(
classify_entropy(2.0),
EntropyClassification::Plaintext
);
assert_eq!(
classify_entropy(5.0),
EntropyClassification::NativeCode
);
assert_eq!(
classify_entropy(6.5),
EntropyClassification::Compressed
);
assert_eq!(
classify_entropy(7.1),
EntropyClassification::Packed
);
assert_eq!(
classify_entropy(7.5),
EntropyClassification::Encrypted
);
}
#[test]
fn packer_section_name_detection() {
assert_eq!(
detect_packer_by_section("UPX0"),
Some("UPX")
);
assert_eq!(
detect_packer_by_section("UPX1"),
Some("UPX")
);
assert_eq!(
detect_packer_by_section(".vmp0"),
Some("VMProtect")
);
assert_eq!(
detect_packer_by_section(".themida"),
Some("Themida")
);
assert_eq!(
detect_packer_by_section(".text"),
None
);
}
#[test]
fn section_flags_high_entropy() {
let sections = vec![SectionInfo {
name: ".text".into(),
virtual_address: 0x1000,
virtual_size: 0x1000,
raw_offset: 0,
raw_size: 256,
permissions: SectionPermissions {
read: true,
write: false,
execute: true,
},
sha256: String::new(),
}];
let data: Vec<u8> =
(0..=255u8).cycle().take(256).collect();
let result =
analyze_entropy(&data, &sections, 0x1000);
let text_section = &result.sections[0];
assert!(
text_section.entropy > HIGH_ENTROPY_THRESHOLD
);
assert!(text_section
.flags
.contains(&EntropyFlag::HighEntropy));
assert!(text_section.is_anomalous);
}
#[test]
fn packer_detection_by_section_name() {
let sections = vec![
SectionInfo {
name: "UPX0".into(),
virtual_address: 0x1000,
virtual_size: 0x10000,
raw_offset: 0,
raw_size: 0,
permissions: SectionPermissions {
read: true,
write: true,
execute: true,
},
sha256: String::new(),
},
SectionInfo {
name: "UPX1".into(),
virtual_address: 0x11000,
virtual_size: 0x5000,
raw_offset: 0x200,
raw_size: 0x4000,
permissions: SectionPermissions {
read: true,
write: false,
execute: true,
},
sha256: String::new(),
},
];
let data = vec![0u8; 0x4200];
let result =
analyze_entropy(&data, &sections, 0x11000);
assert!(result.packing_detected);
assert_eq!(
result.packer_name,
Some("UPX".into())
);
assert!(!result.packing_indicators.is_empty());
}
#[test]
fn elf_entropy_analysis() {
let data = load_fixture("hello_elf");
let format_result =
crate::formats::parse_format(&data)
.unwrap();
let result = analyze_entropy(
&data,
&format_result.sections,
format_result.entry_point,
);
assert!(result.overall_entropy > 0.0);
assert!(!result.sections.is_empty());
assert!(!result.packing_detected);
}
#[test]
fn entropy_pass_populates_context() {
let data = load_fixture("hello_elf");
let mut ctx = make_ctx(data);
crate::passes::format::FormatPass
.run(&mut ctx)
.unwrap();
assert!(ctx.format_result.is_some());
EntropyPass.run(&mut ctx).unwrap();
assert!(ctx.entropy_result.is_some());
let result = ctx.entropy_result.unwrap();
assert!(result.overall_entropy > 0.0);
}
}

View File

@ -0,0 +1,183 @@
// ©AngelaMos | 2026
// format.rs
use crate::context::AnalysisContext;
use crate::error::EngineError;
use crate::formats;
use crate::pass::{AnalysisPass, Sealed};
pub struct FormatPass;
impl Sealed for FormatPass {}
impl AnalysisPass for FormatPass {
fn name(&self) -> &'static str {
"format"
}
fn dependencies(&self) -> &[&'static str] {
&[]
}
fn run(
&self,
ctx: &mut AnalysisContext,
) -> Result<(), EngineError> {
let result =
formats::parse_format(ctx.data())?;
ctx.format_result = Some(result);
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::context::{AnalysisContext, BinarySource};
use crate::formats::{self, FormatAnomaly};
use crate::types::{Architecture, BinaryFormat, Endianness};
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture {path}: {e}")
})
}
fn make_ctx(data: Vec<u8>) -> AnalysisContext {
let size = data.len() as u64;
AnalysisContext::new(
BinarySource::Buffered(Arc::from(data)),
"deadbeef".into(),
"test.bin".into(),
size,
)
}
#[test]
fn parse_elf_basic_metadata() {
let data = load_fixture("hello_elf");
let result =
formats::parse_format(&data).unwrap();
assert_eq!(result.format, BinaryFormat::Elf);
assert_eq!(
result.architecture,
Architecture::X86_64
);
assert_eq!(result.bits, 64);
assert_eq!(
result.endianness,
Endianness::Little
);
assert!(result.entry_point > 0);
assert!(result.is_pie);
assert!(!result.is_stripped);
}
#[test]
fn parse_elf_sections_present() {
let data = load_fixture("hello_elf");
let result =
formats::parse_format(&data).unwrap();
assert!(!result.sections.is_empty());
let text = result
.sections
.iter()
.find(|s| s.name == ".text");
assert!(text.is_some());
assert!(text.unwrap().permissions.execute);
}
#[test]
fn parse_elf_segments_present() {
let data = load_fixture("hello_elf");
let result =
formats::parse_format(&data).unwrap();
assert!(!result.segments.is_empty());
let load_segments: Vec<_> = result
.segments
.iter()
.filter(|s| {
s.name.as_deref() == Some("LOAD")
})
.collect();
assert!(!load_segments.is_empty());
}
#[test]
fn parse_elf_stripped_detection() {
let data =
load_fixture("hello_elf_stripped");
let result =
formats::parse_format(&data).unwrap();
assert!(result.is_stripped);
assert!(result.anomalies.iter().any(|a| {
matches!(
a,
FormatAnomaly::StrippedBinary
)
}));
}
#[test]
fn parse_elf_info_populated() {
let data = load_fixture("hello_elf");
let result =
formats::parse_format(&data).unwrap();
let elf_info = result.elf_info.unwrap();
assert!(!elf_info.os_abi.is_empty());
assert!(!elf_info.elf_type.is_empty());
assert!(elf_info.interpreter.is_some());
assert!(elf_info.gnu_relro);
}
#[test]
fn parse_elf_section_hashes() {
let data = load_fixture("hello_elf");
let result =
formats::parse_format(&data).unwrap();
let text = result
.sections
.iter()
.find(|s| s.name == ".text")
.unwrap();
assert!(
!text.sha256.is_empty(),
".text section should have a hash"
);
assert_eq!(text.sha256.len(), 64);
}
#[test]
fn parse_invalid_binary() {
let data = vec![0xDE, 0xAD, 0xBE, 0xEF];
let result = formats::parse_format(&data);
assert!(result.is_err());
}
#[test]
fn format_pass_populates_context() {
use crate::pass::AnalysisPass;
use super::FormatPass;
let data = load_fixture("hello_elf");
let mut ctx = make_ctx(data);
assert!(ctx.format_result.is_none());
FormatPass.run(&mut ctx).unwrap();
assert!(ctx.format_result.is_some());
let fmt = ctx.format_result.unwrap();
assert_eq!(fmt.format, BinaryFormat::Elf);
}
}

View File

@ -0,0 +1,908 @@
// ©AngelaMos | 2026
// imports.rs
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::context::AnalysisContext;
use crate::error::EngineError;
use crate::pass::{AnalysisPass, Sealed};
use crate::types::Severity;
pub struct SuspiciousApiDef {
pub name: &'static str,
pub tag: &'static str,
pub mitre_id: &'static str,
}
pub const SUSPICIOUS_APIS: &[SuspiciousApiDef] = &[
SuspiciousApiDef {
name: "VirtualAllocEx",
tag: "injection",
mitre_id: "T1055",
},
SuspiciousApiDef {
name: "WriteProcessMemory",
tag: "injection",
mitre_id: "T1055",
},
SuspiciousApiDef {
name: "CreateRemoteThread",
tag: "injection",
mitre_id: "T1055",
},
SuspiciousApiDef {
name: "NtUnmapViewOfSection",
tag: "hollowing",
mitre_id: "T1055.012",
},
SuspiciousApiDef {
name: "SetThreadContext",
tag: "hollowing",
mitre_id: "T1055.012",
},
SuspiciousApiDef {
name: "QueueUserAPC",
tag: "apc-injection",
mitre_id: "T1055.004",
},
SuspiciousApiDef {
name: "IsDebuggerPresent",
tag: "anti-debug",
mitre_id: "T1622",
},
SuspiciousApiDef {
name: "NtQueryInformationProcess",
tag: "anti-debug",
mitre_id: "T1622",
},
SuspiciousApiDef {
name: "OpenProcessToken",
tag: "token-manipulation",
mitre_id: "T1134",
},
SuspiciousApiDef {
name: "AdjustTokenPrivileges",
tag: "token-manipulation",
mitre_id: "T1134",
},
SuspiciousApiDef {
name: "RegSetValueEx",
tag: "persistence",
mitre_id: "T1547.001",
},
SuspiciousApiDef {
name: "CreateService",
tag: "persistence",
mitre_id: "T1543.003",
},
SuspiciousApiDef {
name: "URLDownloadToFile",
tag: "download",
mitre_id: "T1105",
},
SuspiciousApiDef {
name: "InternetOpen",
tag: "network",
mitre_id: "T1071",
},
SuspiciousApiDef {
name: "CryptDecrypt",
tag: "deobfuscation",
mitre_id: "T1140",
},
SuspiciousApiDef {
name: "ptrace",
tag: "injection",
mitre_id: "T1055.008",
},
SuspiciousApiDef {
name: "mprotect",
tag: "memory-manipulation",
mitre_id: "",
},
SuspiciousApiDef {
name: "dlopen",
tag: "loading",
mitre_id: "T1574.006",
},
SuspiciousApiDef {
name: "dlsym",
tag: "loading",
mitre_id: "T1574.006",
},
SuspiciousApiDef {
name: "execve",
tag: "execution",
mitre_id: "T1059",
},
SuspiciousApiDef {
name: "process_vm_readv",
tag: "injection",
mitre_id: "T1055",
},
SuspiciousApiDef {
name: "process_vm_writev",
tag: "injection",
mitre_id: "T1055",
},
];
struct CombinationDef {
name: &'static str,
description: &'static str,
patterns: &'static [&'static str],
mitre_id: &'static str,
severity: Severity,
}
const SUSPICIOUS_COMBINATIONS: &[CombinationDef] = &[
CombinationDef {
name: "Process Injection Chain",
description: "VirtualAllocEx + WriteProcessMemory \
+ CreateRemoteThread",
patterns: &[
"VirtualAllocEx",
"WriteProcessMemory",
"CreateRemoteThread",
],
mitre_id: "T1055",
severity: Severity::Critical,
},
CombinationDef {
name: "Process Hollowing",
description: "CreateProcess + \
NtUnmapViewOfSection + \
SetThreadContext + ResumeThread",
patterns: &[
"CreateProcess*",
"NtUnmapViewOfSection",
"SetThreadContext",
"ResumeThread",
],
mitre_id: "T1055.012",
severity: Severity::Critical,
},
CombinationDef {
name: "APC Injection",
description: "QueueUserAPC + OpenThread",
patterns: &["QueueUserAPC", "OpenThread"],
mitre_id: "T1055.004",
severity: Severity::High,
},
CombinationDef {
name: "DLL Injection",
description: "LoadLibrary + CreateRemoteThread",
patterns: &[
"LoadLibrary*",
"CreateRemoteThread",
],
mitre_id: "T1055.001",
severity: Severity::High,
},
CombinationDef {
name: "Credential Theft",
description: "OpenProcess + ReadProcessMemory",
patterns: &[
"OpenProcess",
"ReadProcessMemory",
],
mitre_id: "T1003",
severity: Severity::Critical,
},
CombinationDef {
name: "Service Persistence",
description: "OpenSCManager + CreateService",
patterns: &[
"OpenSCManager*",
"CreateService*",
],
mitre_id: "T1543.003",
severity: Severity::Medium,
},
CombinationDef {
name: "Registry Persistence",
description: "RegOpenKeyEx + RegSetValueEx",
patterns: &[
"RegOpenKeyEx*",
"RegSetValueEx*",
],
mitre_id: "T1547.001",
severity: Severity::Medium,
},
CombinationDef {
name: "Download and Execute",
description: "URLDownloadToFile + ShellExecute",
patterns: &[
"URLDownloadToFile*",
"ShellExecute*",
],
mitre_id: "T1105",
severity: Severity::High,
},
CombinationDef {
name: "Download and Execute",
description: "URLDownloadToFile + WinExec",
patterns: &["URLDownloadToFile*", "WinExec"],
mitre_id: "T1105",
severity: Severity::High,
},
CombinationDef {
name: "Linux ptrace Injection",
description: "ptrace-based process injection",
patterns: &["ptrace"],
mitre_id: "T1055.008",
severity: Severity::High,
},
CombinationDef {
name: "Linux RWX Memory",
description: "mmap + mprotect for RWX memory",
patterns: &["mmap", "mprotect"],
mitre_id: "",
severity: Severity::Medium,
},
CombinationDef {
name: "Linux C2 Connection",
description: "socket + connect + inet_pton \
hardcoded C2 address",
patterns: &[
"socket",
"connect",
"inet_pton",
],
mitre_id: "T1071",
severity: Severity::High,
},
CombinationDef {
name: "Linux Network Listener",
description: "socket + bind + listen + accept \
backdoor listener",
patterns: &[
"socket",
"bind",
"listen",
"accept",
],
mitre_id: "T1571",
severity: Severity::High,
},
CombinationDef {
name: "Linux Dynamic Loading",
description: "dlopen + dlsym runtime \
API resolution",
patterns: &["dlopen", "dlsym"],
mitre_id: "T1574.006",
severity: Severity::Medium,
},
CombinationDef {
name: "Linux Process Injection",
description: "process_vm_writev \
cross-process memory write",
patterns: &["process_vm_writev"],
mitre_id: "T1055",
severity: Severity::Critical,
},
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub imports: Vec<ImportEntry>,
pub exports: Vec<ExportEntry>,
pub libraries: Vec<String>,
pub suspicious_combinations:
Vec<SuspiciousCombination>,
pub mitre_mappings: Vec<MitreMapping>,
pub statistics: ImportStatistics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportEntry {
pub library: String,
pub function: String,
pub address: Option<u64>,
pub ordinal: Option<u16>,
pub is_suspicious: bool,
pub threat_tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportEntry {
pub name: Option<String>,
pub address: u64,
pub ordinal: Option<u16>,
pub is_forwarded: bool,
pub forward_target: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuspiciousCombination {
pub name: String,
pub description: String,
pub apis: Vec<String>,
pub mitre_id: String,
pub severity: Severity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MitreMapping {
pub technique_id: String,
pub api: String,
pub tag: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportStatistics {
pub total_imports: usize,
pub total_exports: usize,
pub suspicious_count: usize,
pub library_count: usize,
}
pub struct ImportPass;
impl Sealed for ImportPass {}
impl AnalysisPass for ImportPass {
fn name(&self) -> &'static str {
"imports"
}
fn dependencies(&self) -> &[&'static str] {
&["format"]
}
fn run(
&self,
ctx: &mut AnalysisContext,
) -> Result<(), EngineError> {
let result = analyze_imports(ctx.data())?;
ctx.import_result = Some(result);
Ok(())
}
}
fn analyze_imports(
data: &[u8],
) -> Result<ImportResult, EngineError> {
let object =
goblin::Object::parse(data).map_err(|e| {
EngineError::InvalidBinary {
reason: e.to_string(),
}
})?;
let (imports, exports, libraries) = match &object {
goblin::Object::Elf(elf) => extract_elf(elf),
goblin::Object::PE(pe) => extract_pe(pe),
goblin::Object::Mach(mach) => {
extract_mach(mach, data)?
}
_ => (Vec::new(), Vec::new(), Vec::new()),
};
let suspicious_combinations =
detect_combinations(&imports);
let mitre_mappings =
collect_mitre_mappings(&imports);
let suspicious_count = imports
.iter()
.filter(|i| i.is_suspicious)
.count();
let statistics = ImportStatistics {
total_imports: imports.len(),
total_exports: exports.len(),
suspicious_count,
library_count: libraries.len(),
};
Ok(ImportResult {
imports,
exports,
libraries,
suspicious_combinations,
mitre_mappings,
statistics,
})
}
fn extract_elf(
elf: &goblin::elf::Elf,
) -> (Vec<ImportEntry>, Vec<ExportEntry>, Vec<String>) {
let libraries: Vec<String> = elf
.libraries
.iter()
.map(|s| s.to_string())
.collect();
let mut imports = Vec::new();
for sym in elf.dynsyms.iter() {
if !sym.is_import() || sym.st_name == 0 {
continue;
}
let name = elf
.dynstrtab
.get_at(sym.st_name)
.unwrap_or("");
if name.is_empty() {
continue;
}
let (is_suspicious, threat_tags) =
flag_suspicious(name);
imports.push(ImportEntry {
library: String::new(),
function: name.to_string(),
address: None,
ordinal: None,
is_suspicious,
threat_tags,
});
}
let mut exports = Vec::new();
for sym in elf.dynsyms.iter() {
if sym.is_import()
|| sym.st_value == 0
|| sym.st_name == 0
{
continue;
}
let name = elf
.dynstrtab
.get_at(sym.st_name)
.unwrap_or("");
if name.is_empty() {
continue;
}
exports.push(ExportEntry {
name: Some(name.to_string()),
address: sym.st_value,
ordinal: None,
is_forwarded: false,
forward_target: None,
});
}
(imports, exports, libraries)
}
fn extract_pe(
pe: &goblin::pe::PE,
) -> (Vec<ImportEntry>, Vec<ExportEntry>, Vec<String>) {
let mut lib_set = HashSet::new();
let mut imports = Vec::new();
for import in &pe.imports {
let dll = import.dll.to_string();
lib_set.insert(dll.clone());
let (is_suspicious, threat_tags) =
flag_suspicious(&import.name);
imports.push(ImportEntry {
library: dll,
function: import.name.to_string(),
address: Some(import.rva as u64),
ordinal: Some(import.ordinal),
is_suspicious,
threat_tags,
});
}
let mut exports = Vec::new();
for export in &pe.exports {
let is_forwarded = export.reexport.is_some();
let forward_target =
export.reexport.as_ref().map(|r| match r {
goblin::pe::export::Reexport::DLLName {
export: name,
lib,
} => format!("{lib}!{name}"),
goblin::pe::export::Reexport::DLLOrdinal {
ordinal,
lib,
} => format!("{lib}!#{ordinal}"),
});
exports.push(ExportEntry {
name: export.name.map(|s| s.to_string()),
address: export.rva as u64,
ordinal: None,
is_forwarded,
forward_target,
});
}
let libraries: Vec<String> =
lib_set.into_iter().collect();
(imports, exports, libraries)
}
fn extract_mach(
mach: &goblin::mach::Mach,
data: &[u8],
) -> Result<
(Vec<ImportEntry>, Vec<ExportEntry>, Vec<String>),
EngineError,
> {
match mach {
goblin::mach::Mach::Binary(macho) => {
Ok(extract_single_macho(macho))
}
goblin::mach::Mach::Fat(fat) => {
for arch in fat.iter_arches() {
let arch = arch.map_err(|e| {
EngineError::InvalidBinary {
reason: e.to_string(),
}
})?;
let macho = goblin::mach::MachO::parse(
data,
arch.offset as usize,
)
.map_err(|e| {
EngineError::InvalidBinary {
reason: e.to_string(),
}
})?;
return Ok(extract_single_macho(&macho));
}
Ok((Vec::new(), Vec::new(), Vec::new()))
}
}
}
fn extract_single_macho(
macho: &goblin::mach::MachO,
) -> (Vec<ImportEntry>, Vec<ExportEntry>, Vec<String>) {
let mut imports = Vec::new();
if let Ok(macho_imports) = macho.imports() {
for imp in &macho_imports {
let (is_suspicious, threat_tags) =
flag_suspicious(imp.name);
imports.push(ImportEntry {
library: imp.dylib.to_string(),
function: imp.name.to_string(),
address: Some(imp.address),
ordinal: None,
is_suspicious,
threat_tags,
});
}
}
let mut exports = Vec::new();
if let Ok(macho_exports) = macho.exports() {
for exp in &macho_exports {
exports.push(ExportEntry {
name: Some(exp.name.clone()),
address: exp.offset,
ordinal: None,
is_forwarded: false,
forward_target: None,
});
}
}
let libraries: Vec<String> = macho
.libs
.iter()
.filter(|lib| !lib.is_empty())
.map(|lib| lib.to_string())
.collect();
(imports, exports, libraries)
}
fn flag_suspicious(
name: &str,
) -> (bool, Vec<String>) {
let mut tags = Vec::new();
for api in SUSPICIOUS_APIS {
if matches_api(name, api.name) {
tags.push(api.tag.to_string());
}
}
let is_suspicious = !tags.is_empty();
(is_suspicious, tags)
}
fn matches_api(
import_name: &str,
api_name: &str,
) -> bool {
if import_name == api_name {
return true;
}
if import_name.starts_with(api_name) {
let suffix = &import_name[api_name.len()..];
return suffix == "A" || suffix == "W";
}
false
}
fn matches_pattern(
import_name: &str,
pattern: &str,
) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
import_name.starts_with(prefix)
} else {
matches_api(import_name, pattern)
}
}
fn detect_combinations(
imports: &[ImportEntry],
) -> Vec<SuspiciousCombination> {
let function_names: Vec<&str> = imports
.iter()
.map(|i| i.function.as_str())
.collect();
let mut results = Vec::new();
let mut seen = HashSet::new();
for combo in SUSPICIOUS_COMBINATIONS {
if seen.contains(combo.name) {
continue;
}
let all_matched =
combo.patterns.iter().all(|pattern| {
function_names.iter().any(|name| {
matches_pattern(name, pattern)
})
});
if !all_matched {
continue;
}
let matched_apis: Vec<String> = combo
.patterns
.iter()
.filter_map(|pattern| {
function_names
.iter()
.find(|name| {
matches_pattern(name, pattern)
})
.map(|name| name.to_string())
})
.collect();
results.push(SuspiciousCombination {
name: combo.name.into(),
description: combo.description.into(),
apis: matched_apis,
mitre_id: combo.mitre_id.into(),
severity: combo.severity.clone(),
});
seen.insert(combo.name);
}
results
}
fn collect_mitre_mappings(
imports: &[ImportEntry],
) -> Vec<MitreMapping> {
let mut mappings = Vec::new();
for import in imports {
if !import.is_suspicious {
continue;
}
for api in SUSPICIOUS_APIS {
if matches_api(&import.function, api.name)
&& !api.mitre_id.is_empty()
{
mappings.push(MitreMapping {
technique_id: api.mitre_id.into(),
api: import.function.clone(),
tag: api.tag.into(),
});
}
}
}
mappings
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::context::BinarySource;
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture {path}: {e}")
})
}
fn make_ctx(data: Vec<u8>) -> AnalysisContext {
let size = data.len() as u64;
AnalysisContext::new(
BinarySource::Buffered(Arc::from(data)),
"deadbeef".into(),
"test.bin".into(),
size,
)
}
#[test]
fn elf_imports_extracted() {
let data = load_fixture("hello_elf");
let result =
analyze_imports(&data).unwrap();
assert!(
!result.imports.is_empty(),
"ELF binary should have imports"
);
assert!(
!result.libraries.is_empty(),
"ELF binary should list needed libraries"
);
assert!(result.statistics.total_imports > 0);
}
#[test]
fn suspicious_api_flagging() {
let (is_suspicious, tags) =
flag_suspicious("VirtualAllocEx");
assert!(is_suspicious);
assert!(tags.contains(&"injection".to_string()));
let (is_suspicious, tags) =
flag_suspicious("RegSetValueExW");
assert!(is_suspicious);
assert!(tags.contains(
&"persistence".to_string()
));
let (is_suspicious, _) =
flag_suspicious("printf");
assert!(!is_suspicious);
}
#[test]
fn combination_detection_injection_chain() {
let imports = vec![
ImportEntry {
library: "kernel32.dll".into(),
function: "VirtualAllocEx".into(),
address: None,
ordinal: None,
is_suspicious: true,
threat_tags: vec![
"injection".into(),
],
},
ImportEntry {
library: "kernel32.dll".into(),
function: "WriteProcessMemory".into(),
address: None,
ordinal: None,
is_suspicious: true,
threat_tags: vec![
"injection".into(),
],
},
ImportEntry {
library: "kernel32.dll".into(),
function: "CreateRemoteThread".into(),
address: None,
ordinal: None,
is_suspicious: true,
threat_tags: vec![
"injection".into(),
],
},
];
let combos = detect_combinations(&imports);
assert_eq!(combos.len(), 1);
assert_eq!(
combos[0].name,
"Process Injection Chain"
);
assert_eq!(combos[0].mitre_id, "T1055");
assert_eq!(
combos[0].severity,
Severity::Critical
);
}
#[test]
fn combination_with_aw_suffix() {
let imports = vec![
ImportEntry {
library: "advapi32.dll".into(),
function: "RegOpenKeyExA".into(),
address: None,
ordinal: None,
is_suspicious: false,
threat_tags: vec![],
},
ImportEntry {
library: "advapi32.dll".into(),
function: "RegSetValueExW".into(),
address: None,
ordinal: None,
is_suspicious: true,
threat_tags: vec![
"persistence".into(),
],
},
];
let combos = detect_combinations(&imports);
assert!(combos
.iter()
.any(|c| c.name
== "Registry Persistence"));
}
#[test]
fn no_false_positive_combinations() {
let imports = vec![ImportEntry {
library: "kernel32.dll".into(),
function: "VirtualAllocEx".into(),
address: None,
ordinal: None,
is_suspicious: true,
threat_tags: vec!["injection".into()],
}];
let combos = detect_combinations(&imports);
assert!(
!combos.iter().any(|c| c.name
== "Process Injection Chain"),
"should not detect chain with only one API"
);
}
#[test]
fn mitre_mappings_collected() {
let imports = vec![
ImportEntry {
library: String::new(),
function: "ptrace".into(),
address: None,
ordinal: None,
is_suspicious: true,
threat_tags: vec![
"injection".into(),
],
},
ImportEntry {
library: String::new(),
function: "printf".into(),
address: None,
ordinal: None,
is_suspicious: false,
threat_tags: vec![],
},
];
let mappings =
collect_mitre_mappings(&imports);
assert_eq!(mappings.len(), 1);
assert_eq!(
mappings[0].technique_id,
"T1055.008"
);
assert_eq!(mappings[0].api, "ptrace");
}
#[test]
fn import_pass_populates_context() {
let data = load_fixture("hello_elf");
let mut ctx = make_ctx(data);
assert!(ctx.import_result.is_none());
ImportPass.run(&mut ctx).unwrap();
assert!(ctx.import_result.is_some());
}
}

View File

@ -0,0 +1,9 @@
// ©AngelaMos | 2026
// mod.rs
pub mod disasm;
pub mod entropy;
pub mod format;
pub mod imports;
pub mod strings;
pub mod threat;

View File

@ -0,0 +1,897 @@
// ©AngelaMos | 2026
// strings.rs
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::context::AnalysisContext;
use crate::error::EngineError;
use crate::formats::SectionInfo;
use crate::pass::{AnalysisPass, Sealed};
use crate::passes::imports::SUSPICIOUS_APIS;
use crate::types::{StringCategory, StringEncoding};
const MIN_STRING_LENGTH: usize = 4;
const PRINTABLE_MIN: u8 = 0x20;
const PRINTABLE_MAX: u8 = 0x7E;
const TAB: u8 = 0x09;
const NEWLINE: u8 = 0x0A;
const CARRIAGE_RETURN: u8 = 0x0D;
const SUSPICIOUS_CATEGORIES: &[StringCategory] = &[
StringCategory::SuspiciousApi,
StringCategory::PackerSignature,
StringCategory::AntiAnalysis,
StringCategory::PersistencePath,
StringCategory::EncodedData,
StringCategory::ShellCommand,
StringCategory::CryptoWallet,
];
const URL_PREFIXES: &[&str] =
&["http://", "https://", "ftp://"];
const UNIX_PATH_PREFIXES: &[&str] = &[
"/etc/", "/tmp/", "/var/", "/bin/", "/usr/",
"/dev/", "/proc/", "/sys/", "/opt/", "/home/",
"/root/", "/lib/", "/sbin/",
];
const REGISTRY_PREFIXES: &[&str] = &[
"HKEY_",
"HKLM\\",
"HKCU\\",
"HKCR\\",
"HKCC\\",
"HKU\\",
];
const SHELL_INDICATORS: &[&str] = &[
"cmd.exe",
"cmd /c",
"cmd /k",
"powershell",
"pwsh",
"/bin/sh",
"/bin/bash",
"/bin/zsh",
"bash -c",
"sh -c",
"| bash",
"|bash",
"| /bin/sh",
"|/bin/sh",
"| /bin/bash",
"|/bin/bash",
];
const PACKER_SIGNATURES: &[&str] = &[
"UPX!", "MPRESS", ".themida", ".vmp", ".enigma",
"PEC2", "ASPack", "MEW ",
];
const DEBUG_ARTIFACTS: &[&str] = &[
"/rustc/",
".cargo/registry/",
"panicked at",
".pdb",
"_ZN",
".debug_",
"DWARF",
];
const ANTI_ANALYSIS_INDICATORS: &[&str] = &[
"VMware",
"VirtualBox",
"VBox",
"QEMU",
"sandbox",
"Sandboxie",
"wireshark",
"procmon",
"x64dbg",
"x32dbg",
"ollydbg",
"IDA Pro",
"Ghidra",
"Immunity",
"SbieDll",
"dbghelp",
"wine_get_unix_file_name",
"TracerPid",
"/proc/self/status",
"/proc/self/maps",
];
const PERSISTENCE_PATHS: &[&str] = &[
"CurrentVersion\\Run",
"CurrentVersion\\RunOnce",
"CurrentVersion\\RunServices",
"/etc/cron",
"/etc/init.d/",
"/etc/systemd/",
".bashrc",
".bash_profile",
".profile",
"/etc/rc.local",
"crontab",
"launchd",
"LaunchAgents",
"LaunchDaemons",
".config/autostart",
];
const BASE64_CHARS: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const BASE64_MIN_LENGTH: usize = 20;
const IP_OCTET_MAX: u32 = 255;
const IP_OCTET_COUNT: usize = 4;
const BTC_MIN_LENGTH: usize = 26;
const BTC_MAX_LENGTH: usize = 35;
const ETH_ADDRESS_LENGTH: usize = 42;
const ETH_PREFIX: &str = "0x";
const ETH_HEX_DIGITS: usize = 40;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StringResult {
pub strings: Vec<ExtractedString>,
pub statistics: StringStatistics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedString {
pub value: String,
pub offset: u64,
pub encoding: StringEncoding,
pub length: usize,
pub category: StringCategory,
pub is_suspicious: bool,
pub section: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StringStatistics {
pub total: usize,
pub by_encoding: HashMap<StringEncoding, usize>,
pub by_category: HashMap<StringCategory, usize>,
pub suspicious_count: usize,
}
pub struct StringPass;
impl Sealed for StringPass {}
impl AnalysisPass for StringPass {
fn name(&self) -> &'static str {
"strings"
}
fn dependencies(&self) -> &[&'static str] {
&["format"]
}
fn run(
&self,
ctx: &mut AnalysisContext,
) -> Result<(), EngineError> {
let sections = ctx
.format_result
.as_ref()
.map(|fr| fr.sections.as_slice());
let result =
extract_strings(ctx.data(), sections);
ctx.string_result = Some(result);
Ok(())
}
}
fn extract_strings(
data: &[u8],
sections: Option<&[SectionInfo]>,
) -> StringResult {
let mut strings = Vec::new();
extract_ascii(data, sections, &mut strings);
extract_utf16le(data, sections, &mut strings);
let mut by_encoding = HashMap::new();
let mut by_category = HashMap::new();
let mut suspicious_count = 0;
for s in &strings {
*by_encoding
.entry(s.encoding.clone())
.or_insert(0) += 1;
*by_category
.entry(s.category.clone())
.or_insert(0) += 1;
if s.is_suspicious {
suspicious_count += 1;
}
}
let total = strings.len();
let statistics = StringStatistics {
total,
by_encoding,
by_category,
suspicious_count,
};
StringResult {
strings,
statistics,
}
}
fn is_printable(b: u8) -> bool {
(PRINTABLE_MIN..=PRINTABLE_MAX).contains(&b)
|| b == TAB
|| b == NEWLINE
|| b == CARRIAGE_RETURN
}
fn extract_ascii(
data: &[u8],
sections: Option<&[SectionInfo]>,
out: &mut Vec<ExtractedString>,
) {
let mut start = 0;
let mut in_run = false;
for (i, &byte) in data.iter().enumerate() {
if is_printable(byte) {
if !in_run {
start = i;
in_run = true;
}
} else if in_run {
let len = i - start;
if len >= MIN_STRING_LENGTH {
if let Ok(s) =
std::str::from_utf8(&data[start..i])
{
let is_multibyte = s
.bytes()
.any(|b| !b.is_ascii());
let encoding = if is_multibyte {
StringEncoding::Utf8
} else {
StringEncoding::Ascii
};
let category = classify(s);
let is_suspicious =
is_category_suspicious(&category);
let section = sections.and_then(|secs| {
find_section(
secs,
start as u64,
)
});
out.push(ExtractedString {
value: s.to_string(),
offset: start as u64,
encoding,
length: len,
category,
is_suspicious,
section,
});
}
}
in_run = false;
}
}
if in_run {
let len = data.len() - start;
if len >= MIN_STRING_LENGTH {
if let Ok(s) =
std::str::from_utf8(&data[start..])
{
let is_multibyte =
s.bytes().any(|b| !b.is_ascii());
let encoding = if is_multibyte {
StringEncoding::Utf8
} else {
StringEncoding::Ascii
};
let category = classify(s);
let is_suspicious =
is_category_suspicious(&category);
let section = sections.and_then(|secs| {
find_section(secs, start as u64)
});
out.push(ExtractedString {
value: s.to_string(),
offset: start as u64,
encoding,
length: len,
category,
is_suspicious,
section,
});
}
}
}
}
fn extract_utf16le(
data: &[u8],
sections: Option<&[SectionInfo]>,
out: &mut Vec<ExtractedString>,
) {
let mut i = 0;
while i + 1 < data.len() {
let lo = data[i];
let hi = data[i + 1];
if hi == 0x00 && is_printable(lo) {
let start = i;
let mut code_units = Vec::new();
let mut pos = i;
while pos + 1 < data.len() {
let clo = data[pos];
let chi = data[pos + 1];
if chi == 0x00 && clo == 0x00 {
break;
}
if chi == 0x00 && is_printable(clo) {
code_units.push(u16::from(clo));
pos += 2;
} else {
break;
}
}
if code_units.len() >= MIN_STRING_LENGTH {
let value =
String::from_utf16_lossy(&code_units);
let category = classify(&value);
let is_suspicious =
is_category_suspicious(&category);
let section = sections.and_then(|secs| {
find_section(secs, start as u64)
});
out.push(ExtractedString {
value,
offset: start as u64,
encoding: StringEncoding::Utf16Le,
length: code_units.len(),
category,
is_suspicious,
section,
});
}
i = if pos > i { pos } else { i + 2 };
} else {
i += 2;
}
}
}
fn find_section(
sections: &[SectionInfo],
file_offset: u64,
) -> Option<String> {
sections
.iter()
.find(|s| {
s.raw_size > 0
&& file_offset >= s.raw_offset
&& file_offset
< s.raw_offset + s.raw_size
})
.map(|s| s.name.clone())
}
fn classify(s: &str) -> StringCategory {
if is_url(s) {
return StringCategory::Url;
}
if is_ip_address(s) {
return StringCategory::IpAddress;
}
if is_registry_key(s) {
return StringCategory::RegistryKey;
}
if is_shell_command(s) {
return StringCategory::ShellCommand;
}
if is_persistence_path(s) {
return StringCategory::PersistencePath;
}
if is_anti_analysis(s) {
return StringCategory::AntiAnalysis;
}
if is_packer_signature(s) {
return StringCategory::PackerSignature;
}
if is_suspicious_api(s) {
return StringCategory::SuspiciousApi;
}
if is_debug_artifact(s) {
return StringCategory::DebugArtifact;
}
if is_file_path(s) {
return StringCategory::FilePath;
}
if is_crypto_wallet(s) {
return StringCategory::CryptoWallet;
}
if is_email(s) {
return StringCategory::Email;
}
if is_encoded_data(s) {
return StringCategory::EncodedData;
}
StringCategory::Generic
}
fn is_category_suspicious(
category: &StringCategory,
) -> bool {
SUSPICIOUS_CATEGORIES.contains(category)
}
fn is_url(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
URL_PREFIXES
.iter()
.any(|prefix| lower.starts_with(prefix))
}
fn is_ip_address(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != IP_OCTET_COUNT {
return false;
}
parts.iter().all(|part| {
if part.is_empty() || part.len() > 3 {
return false;
}
match part.parse::<u32>() {
Ok(val) => val <= IP_OCTET_MAX,
Err(_) => false,
}
})
}
fn is_file_path(s: &str) -> bool {
if s.len() >= 3
&& s.as_bytes()[0].is_ascii_uppercase()
&& s.as_bytes()[1] == b':'
&& s.as_bytes()[2] == b'\\'
{
return true;
}
if s.starts_with("\\\\") {
return true;
}
UNIX_PATH_PREFIXES
.iter()
.any(|prefix| s.starts_with(prefix))
}
fn is_registry_key(s: &str) -> bool {
REGISTRY_PREFIXES
.iter()
.any(|prefix| s.starts_with(prefix))
}
fn is_shell_command(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
SHELL_INDICATORS
.iter()
.any(|indicator| lower.contains(indicator))
}
fn is_crypto_wallet(s: &str) -> bool {
if s.len() >= BTC_MIN_LENGTH
&& s.len() <= BTC_MAX_LENGTH
&& (s.starts_with('1') || s.starts_with('3'))
&& s.chars()
.all(|c| c.is_ascii_alphanumeric())
&& !s.chars().any(|c| {
c == '0' || c == 'O' || c == 'I' || c == 'l'
})
{
return true;
}
if s.len() == ETH_ADDRESS_LENGTH
&& s.starts_with(ETH_PREFIX)
&& s[2..].len() == ETH_HEX_DIGITS
&& s[2..].chars().all(|c| c.is_ascii_hexdigit())
{
return true;
}
false
}
fn is_email(s: &str) -> bool {
let at_pos = match s.find('@') {
Some(p) if p > 0 => p,
_ => return false,
};
let domain = &s[at_pos + 1..];
let dot_pos = match domain.rfind('.') {
Some(p) if p > 0 => p,
_ => return false,
};
let tld = &domain[dot_pos + 1..];
if tld.len() < 2 {
return false;
}
let local = &s[..at_pos];
local
.chars()
.all(|c| c.is_ascii_alphanumeric()
|| c == '.'
|| c == '_'
|| c == '%'
|| c == '+'
|| c == '-')
&& domain[..dot_pos]
.chars()
.all(|c| c.is_ascii_alphanumeric()
|| c == '.'
|| c == '-')
&& tld.chars().all(|c| c.is_ascii_alphabetic())
}
fn is_suspicious_api(s: &str) -> bool {
SUSPICIOUS_APIS
.iter()
.any(|api| s == api.name)
}
fn is_packer_signature(s: &str) -> bool {
PACKER_SIGNATURES
.iter()
.any(|sig| s.contains(sig))
}
fn is_debug_artifact(s: &str) -> bool {
DEBUG_ARTIFACTS
.iter()
.any(|artifact| s.contains(artifact))
}
fn is_anti_analysis(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
ANTI_ANALYSIS_INDICATORS.iter().any(|indicator| {
lower.contains(&indicator.to_ascii_lowercase())
})
}
fn is_persistence_path(s: &str) -> bool {
PERSISTENCE_PATHS
.iter()
.any(|path| s.contains(path))
}
fn is_encoded_data(s: &str) -> bool {
if s.len() < BASE64_MIN_LENGTH {
return false;
}
let trimmed = s.trim_end_matches('=');
if trimmed.is_empty() {
return false;
}
let all_base64 = trimmed
.bytes()
.all(|b| BASE64_CHARS.contains(&b));
if !all_base64 {
return false;
}
let padding = s.len() - trimmed.len();
padding <= 2
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::context::BinarySource;
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture {path}: {e}")
})
}
fn make_ctx(data: Vec<u8>) -> AnalysisContext {
let size = data.len() as u64;
AnalysisContext::new(
BinarySource::Buffered(Arc::from(data)),
"deadbeef".into(),
"test.bin".into(),
size,
)
}
#[test]
fn ascii_extraction_min_length() {
let data =
b"abc\x00abcdef\x00ab\x00longstring\x00";
let result = extract_strings(data, None);
let values: Vec<&str> = result
.strings
.iter()
.map(|s| s.value.as_str())
.collect();
assert!(
!values.contains(&"abc"),
"3-char string should be excluded"
);
assert!(
!values.contains(&"ab"),
"2-char string should be excluded"
);
assert!(
values.contains(&"abcdef"),
"6-char string should be included"
);
assert!(
values.contains(&"longstring"),
"10-char string should be included"
);
}
#[test]
fn utf16le_extraction() {
let s = "Hello World!";
let mut data: Vec<u8> = Vec::new();
for c in s.encode_utf16() {
data.extend_from_slice(&c.to_le_bytes());
}
data.push(0x00);
data.push(0x00);
let result = extract_strings(&data, None);
let utf16_strings: Vec<&ExtractedString> =
result
.strings
.iter()
.filter(|s| {
s.encoding == StringEncoding::Utf16Le
})
.collect();
assert!(
!utf16_strings.is_empty(),
"should extract UTF-16LE string"
);
assert!(utf16_strings
.iter()
.any(|s| s.value.contains("Hello")));
}
#[test]
fn category_url() {
assert_eq!(
classify("https://evil.com/payload"),
StringCategory::Url,
);
assert_eq!(
classify("http://malware.ru/dropper"),
StringCategory::Url,
);
assert_eq!(
classify("ftp://files.example.com"),
StringCategory::Url,
);
}
#[test]
fn category_ip_address() {
assert_eq!(
classify("192.168.1.1"),
StringCategory::IpAddress,
);
assert_eq!(
classify("10.0.0.1"),
StringCategory::IpAddress,
);
assert_ne!(
classify("999.999.999.999"),
StringCategory::IpAddress,
);
assert_ne!(
classify("1.2.3"),
StringCategory::IpAddress,
);
}
#[test]
fn category_registry_key() {
assert_eq!(
classify("HKLM\\Software\\Microsoft"),
StringCategory::RegistryKey,
);
assert_eq!(
classify("HKEY_LOCAL_MACHINE"),
StringCategory::RegistryKey,
);
}
#[test]
fn category_file_path() {
assert_eq!(
classify("C:\\Windows\\System32\\notepad.exe"),
StringCategory::FilePath,
);
assert_eq!(
classify("/tmp/output.log"),
StringCategory::FilePath,
);
assert_eq!(
classify("\\\\server\\share"),
StringCategory::FilePath,
);
}
#[test]
fn category_shell_command() {
assert_eq!(
classify("cmd.exe /c whoami"),
StringCategory::ShellCommand,
);
assert_eq!(
classify("/bin/bash -c echo hi"),
StringCategory::ShellCommand,
);
}
#[test]
fn category_packer_signature() {
assert_eq!(
classify("UPX!"),
StringCategory::PackerSignature,
);
assert_eq!(
classify("This is .themida packed"),
StringCategory::PackerSignature,
);
}
#[test]
fn category_anti_analysis() {
assert_eq!(
classify("VMware Virtual Platform"),
StringCategory::AntiAnalysis,
);
assert_eq!(
classify("wireshark.exe"),
StringCategory::AntiAnalysis,
);
}
#[test]
fn category_persistence() {
assert_eq!(
classify(
"Software\\Microsoft\\Windows\\\
CurrentVersion\\Run"
),
StringCategory::PersistencePath,
);
assert_eq!(
classify("/etc/crontab"),
StringCategory::PersistencePath,
);
}
#[test]
fn category_encoded_data() {
assert_eq!(
classify(
"SGVsbG8gV29ybGQhIFRoaXMgaXM="
),
StringCategory::EncodedData,
);
assert_ne!(
classify("short"),
StringCategory::EncodedData,
);
}
#[test]
fn category_email() {
assert_eq!(
classify("user@example.com"),
StringCategory::Email,
);
}
#[test]
fn suspicious_flag_by_category() {
assert!(is_category_suspicious(
&StringCategory::ShellCommand
));
assert!(is_category_suspicious(
&StringCategory::AntiAnalysis
));
assert!(is_category_suspicious(
&StringCategory::PackerSignature
));
assert!(is_category_suspicious(
&StringCategory::PersistencePath
));
assert!(is_category_suspicious(
&StringCategory::EncodedData
));
assert!(is_category_suspicious(
&StringCategory::CryptoWallet
));
assert!(is_category_suspicious(
&StringCategory::SuspiciousApi
));
assert!(!is_category_suspicious(
&StringCategory::Generic
));
assert!(!is_category_suspicious(
&StringCategory::Url
));
}
#[test]
fn elf_strings_extracted() {
let data = load_fixture("hello_elf");
let result = extract_strings(&data, None);
assert!(
!result.strings.is_empty(),
"ELF binary should contain strings"
);
assert!(result.statistics.total > 0);
}
#[test]
fn string_pass_populates_context() {
let data = load_fixture("hello_elf");
let mut ctx = make_ctx(data);
assert!(ctx.string_result.is_none());
StringPass.run(&mut ctx).unwrap();
assert!(ctx.string_result.is_some());
}
#[test]
fn section_attribution() {
let sections = vec![SectionInfo {
name: ".rodata".into(),
virtual_address: 0,
virtual_size: 100,
raw_offset: 10,
raw_size: 100,
permissions:
crate::types::SectionPermissions {
read: true,
write: false,
execute: false,
},
sha256: String::new(),
}];
let found = find_section(&sections, 50);
assert_eq!(found, Some(".rodata".into()));
let not_found = find_section(&sections, 200);
assert_eq!(not_found, None);
}
}

View File

@ -0,0 +1,161 @@
// ©AngelaMos | 2026
// types.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BinaryFormat {
Elf,
Pe,
MachO,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Architecture {
X86,
X86_64,
Arm,
Aarch64,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Endianness {
Little,
Big,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RiskLevel {
Benign,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StringEncoding {
Ascii,
Utf8,
Utf16Le,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StringCategory {
Url,
IpAddress,
FilePath,
RegistryKey,
ShellCommand,
CryptoWallet,
Email,
SuspiciousApi,
PackerSignature,
DebugArtifact,
AntiAnalysis,
PersistencePath,
EncodedData,
Generic,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntropyClassification {
Plaintext,
NativeCode,
Compressed,
Packed,
Encrypted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntropyFlag {
HighEntropy,
HighVirtualToRawRatio,
EmptyRawData,
Rwx,
PackerSectionName,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FlowControlType {
Next,
Branch,
ConditionalBranch,
Call,
Return,
Interrupt,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CfgEdgeType {
Fallthrough,
ConditionalTrue,
ConditionalFalse,
Unconditional,
Call,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SectionPermissions {
pub read: bool,
pub write: bool,
pub execute: bool,
}
impl std::fmt::Display for BinaryFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Elf => write!(f, "ELF"),
Self::Pe => write!(f, "PE"),
Self::MachO => write!(f, "Mach-O"),
}
}
}
impl std::fmt::Display for Architecture {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::X86 => write!(f, "x86"),
Self::X86_64 => write!(f, "x86_64"),
Self::Arm => write!(f, "ARM"),
Self::Aarch64 => write!(f, "AArch64"),
Self::Other(name) => write!(f, "{name}"),
}
}
}
impl std::fmt::Display for RiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Benign => write!(f, "BENIGN"),
Self::Low => write!(f, "LOW"),
Self::Medium => write!(f, "MEDIUM"),
Self::High => write!(f, "HIGH"),
Self::Critical => write!(f, "CRITICAL"),
}
}
}
impl std::fmt::Display for Endianness {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Little => write!(f, "Little-endian"),
Self::Big => write!(f, "Big-endian"),
}
}
}
impl SectionPermissions {
pub fn is_rwx(&self) -> bool {
self.read && self.write && self.execute
}
}

View File

@ -0,0 +1,493 @@
// ©AngelaMos | 2026
// yara.rs
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::EngineError;
const BUILTIN_RULES: &str = r#"
rule suspicious_upx_packed {
meta:
description = "Detects UPX packed binaries"
category = "packer"
severity = "medium"
strings:
$upx0 = "UPX0"
$upx1 = "UPX1"
$upx_magic = { 55 50 58 21 }
condition:
($upx0 and $upx1) or $upx_magic
}
rule suspicious_anti_debug {
meta:
description = "Detects common anti-debugging techniques"
category = "evasion"
severity = "high"
strings:
$api1 = "IsDebuggerPresent"
$api2 = "CheckRemoteDebuggerPresent"
$api3 = "NtQueryInformationProcess"
$api4 = "OutputDebugString"
$int2d = { CD 2D }
condition:
2 of ($api*) or $int2d
}
rule suspicious_process_injection {
meta:
description = "Detects potential process injection capabilities"
category = "injection"
severity = "critical"
strings:
$api1 = "VirtualAllocEx"
$api2 = "WriteProcessMemory"
$api3 = "CreateRemoteThread"
$api4 = "NtUnmapViewOfSection"
condition:
($api1 and $api2 and $api3) or ($api4 and $api2)
}
rule suspicious_keylogger {
meta:
description = "Detects potential keylogger behavior"
category = "spyware"
severity = "high"
strings:
$api1 = "GetAsyncKeyState"
$api2 = "SetWindowsHookEx"
$api3 = "GetKeyState"
$api4 = "GetKeyboardState"
condition:
2 of them
}
rule suspicious_crypto_mining {
meta:
description = "Detects cryptocurrency mining indicators"
category = "miner"
severity = "medium"
strings:
$pool1 = "stratum+tcp://"
$pool2 = "stratum+ssl://"
$algo1 = "cryptonight"
$algo2 = "randomx"
$algo3 = "ethash"
$wallet = /[13][a-km-zA-HJ-NP-Z1-9]{25,34}/
condition:
any of ($pool*) or (any of ($algo*) and $wallet)
}
rule suspicious_persistence {
meta:
description = "Detects Windows persistence mechanisms"
category = "persistence"
severity = "high"
strings:
$reg1 = "CurrentVersion\\Run"
$reg2 = "CurrentVersion\\RunOnce"
$svc1 = "CreateServiceA"
$svc2 = "CreateServiceW"
$task = "schtasks"
condition:
any of them
}
rule suspicious_network_backdoor {
meta:
description = "Detects potential backdoor network behavior"
category = "backdoor"
severity = "critical"
strings:
$bind = "bind"
$listen = "listen"
$accept = "accept"
$shell1 = "cmd.exe"
$shell2 = "/bin/sh"
$shell3 = "/bin/bash"
condition:
($bind and $listen and $accept) and any of ($shell*)
}
rule suspicious_ransomware {
meta:
description = "Detects potential ransomware indicators"
category = "ransomware"
severity = "critical"
strings:
$ext1 = ".encrypted"
$ext2 = ".locked"
$ext3 = ".crypto"
$ransom1 = "your files have been encrypted"
$ransom2 = "bitcoin"
$ransom3 = "decrypt"
$crypto1 = "CryptEncrypt"
$crypto2 = "CryptGenKey"
condition:
(any of ($ext*) and any of ($ransom*)) or
(any of ($crypto*) and any of ($ransom*))
}
rule suspicious_shellcode {
meta:
description = "Detects potential shellcode patterns"
category = "shellcode"
severity = "high"
strings:
$nop_sled = { 90 90 90 90 90 90 90 90 }
$egg_hunter1 = { 66 81 CA FF 0F }
$stack_pivot = { 94 C3 }
condition:
any of them
}
rule suspicious_obfuscation {
meta:
description = "Detects common obfuscation patterns"
category = "obfuscation"
severity = "medium"
strings:
$xor_loop = { 80 3? ?? 74 ?? 80 3? ?? }
$decode_base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
condition:
any of them
}
rule suspicious_linux_anti_debug {
meta:
description = "Detects Linux anti-debugging via /proc inspection"
category = "evasion"
severity = "high"
strings:
$tracer = "TracerPid"
$proc_status = "/proc/self/status"
$proc_maps = "/proc/self/maps"
condition:
$tracer or ($proc_status and $proc_maps)
}
rule suspicious_linux_persistence {
meta:
description = "Detects Linux persistence mechanisms"
category = "persistence"
severity = "high"
strings:
$cron1 = "/etc/cron"
$cron2 = "crontab"
$init = "/etc/init.d/"
$systemd = "/etc/systemd/"
$bashrc = ".bashrc"
$profile = ".bash_profile"
$rc_local = "/etc/rc.local"
$xdg_autostart = ".config/autostart"
condition:
2 of them
}
rule suspicious_c2_endpoints {
meta:
description = "Detects common C2 server endpoint paths"
category = "c2"
severity = "high"
strings:
$gate = "/gate.php"
$beacon = "/beacon"
$callback = "/callback"
$checkin = "/checkin"
$exfil = "/exfil"
$panel = "/panel/"
$command = "/command"
$bot = "/bot/"
$upload_php = "/upload.php"
condition:
2 of them
}
rule suspicious_credential_access {
meta:
description = "Detects credential file access patterns"
category = "credential-access"
severity = "high"
strings:
$passwd = "/etc/passwd"
$shadow = "/etc/shadow"
$ssh_key = ".ssh/id_rsa"
$ssh_key2 = ".ssh/authorized_keys"
$kerberos = "/etc/krb5.conf"
$gnupg = ".gnupg/"
condition:
$shadow or ($passwd and any of ($ssh*, $kerberos, $gnupg))
}
"#;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YaraMatch {
pub rule_name: String,
pub tags: Vec<String>,
pub metadata: YaraMetadata,
pub matched_strings: Vec<YaraStringMatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YaraMetadata {
pub description: Option<String>,
pub category: Option<String>,
pub severity: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YaraStringMatch {
pub identifier: String,
pub match_count: usize,
}
pub struct YaraScanner {
rules: yara_x::Rules,
}
impl YaraScanner {
pub fn new() -> Result<Self, EngineError> {
let mut compiler = yara_x::Compiler::new();
compiler.add_source(BUILTIN_RULES).map_err(
|e| EngineError::Yara(e.to_string()),
)?;
Ok(Self {
rules: compiler.build(),
})
}
pub fn with_custom_rules(
rules_dir: &Path,
) -> Result<Self, EngineError> {
let mut compiler = yara_x::Compiler::new();
compiler.add_source(BUILTIN_RULES).map_err(
|e| EngineError::Yara(e.to_string()),
)?;
if rules_dir.is_dir() {
for entry in std::fs::read_dir(rules_dir)
.map_err(|e| {
EngineError::Yara(format!(
"failed to read rules dir: {e}"
))
})?
{
let entry = entry.map_err(|e| {
EngineError::Yara(format!(
"dir entry error: {e}"
))
})?;
let path = entry.path();
if path
.extension()
.is_some_and(|ext| ext == "yar" || ext == "yara")
{
let source = std::fs::read_to_string(
&path,
)
.map_err(|e| {
EngineError::Yara(format!(
"failed to read {}: {e}",
path.display()
))
})?;
compiler
.add_source(source.as_str())
.map_err(|e| {
EngineError::Yara(format!(
"compile error in {}: {e}",
path.display()
))
})?;
}
}
}
Ok(Self {
rules: compiler.build(),
})
}
pub fn scan(
&self,
data: &[u8],
) -> Result<Vec<YaraMatch>, EngineError> {
let mut scanner =
yara_x::Scanner::new(&self.rules);
let results = scanner.scan(data).map_err(
|e| EngineError::Yara(e.to_string()),
)?;
let mut matches = Vec::new();
for rule in results.matching_rules() {
let tags: Vec<String> = rule
.tags()
.map(|t| t.identifier().to_string())
.collect();
let mut description = None;
let mut category = None;
let mut severity = None;
for (key, value) in rule.metadata() {
match key {
"description" => {
if let yara_x::MetaValue::String(s) = value {
description =
Some(s.to_string());
}
}
"category" => {
if let yara_x::MetaValue::String(s) = value {
category =
Some(s.to_string());
}
}
"severity" => {
if let yara_x::MetaValue::String(s) = value {
severity =
Some(s.to_string());
}
}
_ => {}
}
}
let mut matched_strings = Vec::new();
for pattern in rule.patterns() {
let id = pattern
.identifier()
.to_string();
let count =
pattern.matches().count();
if count > 0 {
matched_strings.push(
YaraStringMatch {
identifier: id,
match_count: count,
},
);
}
}
matches.push(YaraMatch {
rule_name: rule
.identifier()
.to_string(),
tags,
metadata: YaraMetadata {
description,
category,
severity,
},
matched_strings,
});
}
Ok(matches)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_rules_compile() {
let scanner = YaraScanner::new().unwrap();
let result = scanner.scan(&[0u8; 64]).unwrap();
assert!(result.is_empty() || !result.is_empty());
}
#[test]
fn detects_upx_signature() {
let mut data = vec![0u8; 512];
let upx0 = b"UPX0";
let upx1 = b"UPX1";
data[0x100..0x104].copy_from_slice(upx0);
data[0x140..0x144].copy_from_slice(upx1);
let scanner = YaraScanner::new().unwrap();
let result = scanner.scan(&data).unwrap();
let upx_match = result
.iter()
.find(|m| m.rule_name == "suspicious_upx_packed");
assert!(
upx_match.is_some(),
"should detect UPX packer signature"
);
let meta =
&upx_match.unwrap().metadata;
assert_eq!(
meta.category.as_deref(),
Some("packer")
);
}
#[test]
fn detects_process_injection() {
let mut data = Vec::new();
data.extend_from_slice(
b"\x00\x00VirtualAllocEx\x00\x00",
);
data.extend_from_slice(
b"\x00\x00WriteProcessMemory\x00\x00",
);
data.extend_from_slice(
b"\x00\x00CreateRemoteThread\x00\x00",
);
data.extend_from_slice(&[0u8; 256]);
let scanner = YaraScanner::new().unwrap();
let result = scanner.scan(&data).unwrap();
let injection = result.iter().find(|m| {
m.rule_name
== "suspicious_process_injection"
});
assert!(
injection.is_some(),
"should detect process injection APIs"
);
}
#[test]
fn clean_data_no_matches() {
let data = b"Hello, this is perfectly normal text content with nothing suspicious at all.";
let scanner = YaraScanner::new().unwrap();
let result = scanner.scan(data).unwrap();
let suspicious: Vec<_> = result
.iter()
.filter(|m| {
m.rule_name != "suspicious_obfuscation"
})
.collect();
assert!(
suspicious.is_empty(),
"clean text should not trigger suspicious rules, got: {:?}",
suspicious.iter().map(|m| &m.rule_name).collect::<Vec<_>>()
);
}
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture {path}: {e}")
})
}
#[test]
fn scan_elf_binary() {
let data = load_fixture("hello_elf");
let scanner = YaraScanner::new().unwrap();
let result = scanner.scan(&data).unwrap();
assert!(
result.is_empty() || !result.is_empty(),
"scan should complete without error"
);
}
}

View File

@ -0,0 +1,93 @@
// ©AngelaMos | 2026
// integration.rs
use axumortem_engine::types::BinaryFormat;
use axumortem_engine::AnalysisEngine;
fn load_fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/{name}",
env!("CARGO_MANIFEST_DIR"),
);
std::fs::read(&path)
.unwrap_or_else(|e| panic!("fixture {path}: {e}"))
}
#[test]
fn full_pipeline_elf() {
let engine = AnalysisEngine::new().unwrap();
let data = load_fixture("hello_elf");
let (ctx, report) =
engine.analyze(&data, "hello_elf");
assert!(
report.all_succeeded(),
"all passes should succeed: {:?}",
report
.failed_passes()
.iter()
.map(|p| (p.name, p.error_message.as_deref()))
.collect::<Vec<_>>()
);
let fmt = ctx.format_result.as_ref().unwrap();
assert_eq!(fmt.format, BinaryFormat::Elf);
assert!(!fmt.sections.is_empty());
assert!(ctx.import_result.is_some());
assert!(ctx.string_result.is_some());
assert!(ctx.entropy_result.is_some());
assert!(ctx.disassembly_result.is_some());
let disasm =
ctx.disassembly_result.as_ref().unwrap();
assert!(disasm.total_functions > 0);
assert!(disasm.total_instructions > 0);
let threat = ctx.threat_result.as_ref().unwrap();
assert!(threat.total_score <= 100);
assert_eq!(threat.categories.len(), 8);
assert!(!threat.summary.is_empty());
}
#[test]
fn full_pipeline_stripped_elf() {
let engine = AnalysisEngine::new().unwrap();
let data = load_fixture("hello_elf_stripped");
let (ctx, report) =
engine.analyze(&data, "hello_elf_stripped");
assert!(report.all_succeeded());
let fmt = ctx.format_result.as_ref().unwrap();
assert!(fmt.is_stripped);
assert!(ctx.threat_result.is_some());
}
#[test]
fn sha256_computed_correctly() {
let engine = AnalysisEngine::new().unwrap();
let data = load_fixture("hello_elf");
let (ctx, _) = engine.analyze(&data, "test.bin");
assert_eq!(ctx.sha256.len(), 64);
assert!(ctx.sha256.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn invalid_binary_handled() {
let engine = AnalysisEngine::new().unwrap();
let data = vec![0xDE, 0xAD, 0xBE, 0xEF];
let (_, report) =
engine.analyze(&data, "garbage.bin");
assert!(
!report.all_succeeded(),
"invalid binary should cause format pass failure"
);
assert!(report
.failed_passes()
.iter()
.any(|p| p.name == "format"));
}

View File

@ -0,0 +1,31 @@
# ©AngelaMos | 2026
# Cargo.toml
[package]
name = "axumortem"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
axumortem-engine = { path = "../axumortem-engine" }
axum = { version = "0.8", features = ["multipart"] }
tokio = { workspace = true }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"postgres",
"uuid",
"chrono",
"json",
"migrate",
] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }

View File

@ -0,0 +1,31 @@
-- ©AngelaMos | 2026
-- 001_initial.sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE analyses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sha256 TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
file_size BIGINT NOT NULL,
format TEXT NOT NULL,
architecture TEXT NOT NULL,
entry_point BIGINT,
threat_score INTEGER,
risk_level TEXT,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE pass_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
analysis_id UUID NOT NULL REFERENCES analyses(id) ON DELETE CASCADE,
pass_name TEXT NOT NULL,
result JSONB NOT NULL,
duration_ms INTEGER,
UNIQUE(analysis_id, pass_name)
);
CREATE INDEX idx_analyses_sha256 ON analyses(sha256);
CREATE INDEX idx_analyses_slug ON analyses(slug);
CREATE INDEX idx_pass_results_analysis_id ON pass_results(analysis_id);

View File

@ -0,0 +1,33 @@
// ©AngelaMos | 2026
// config.rs
use clap::Parser;
const DEFAULT_HOST: &str = "0.0.0.0";
const DEFAULT_PORT: u16 = 3000;
const DEFAULT_MAX_UPLOAD_BYTES: usize = 52_428_800;
const DEFAULT_CORS_ORIGIN: &str = "*";
#[derive(Parser, Debug)]
pub struct AppConfig {
#[arg(long, env = "DATABASE_URL")]
pub database_url: String,
#[arg(long, env = "HOST", default_value = DEFAULT_HOST)]
pub host: String,
#[arg(long, env = "PORT", default_value_t = DEFAULT_PORT)]
pub port: u16,
#[arg(long, env = "MAX_UPLOAD_SIZE", default_value_t = DEFAULT_MAX_UPLOAD_BYTES)]
pub max_upload_size: usize,
#[arg(long, env = "CORS_ORIGIN", default_value = DEFAULT_CORS_ORIGIN)]
pub cors_origin: String,
}
impl AppConfig {
pub fn bind_address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}

View File

@ -0,0 +1,13 @@
// ©AngelaMos | 2026
// mod.rs
pub mod models;
pub mod queries;
use sqlx::PgPool;
pub async fn run_migrations(
pool: &PgPool,
) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}

View File

@ -0,0 +1,50 @@
// ©AngelaMos | 2026
// models.rs
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;
#[derive(FromRow, Serialize)]
pub struct AnalysisRow {
pub id: Uuid,
pub sha256: String,
pub file_name: String,
pub file_size: i64,
pub format: String,
pub architecture: String,
pub entry_point: Option<i64>,
pub threat_score: Option<i32>,
pub risk_level: Option<String>,
pub slug: String,
pub created_at: DateTime<Utc>,
}
#[derive(FromRow)]
pub struct PassResultRow {
pub id: Uuid,
pub analysis_id: Uuid,
pub pass_name: String,
pub result: serde_json::Value,
pub duration_ms: Option<i32>,
}
pub struct NewAnalysis {
pub sha256: String,
pub file_name: String,
pub file_size: i64,
pub format: String,
pub architecture: String,
pub entry_point: Option<i64>,
pub threat_score: Option<i32>,
pub risk_level: Option<String>,
pub slug: String,
}
pub struct NewPassResult {
pub analysis_id: Uuid,
pub pass_name: String,
pub result: serde_json::Value,
pub duration_ms: Option<i32>,
}

View File

@ -0,0 +1,92 @@
// ©AngelaMos | 2026
// queries.rs
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use super::models::{
AnalysisRow, NewAnalysis, NewPassResult,
PassResultRow,
};
pub async fn find_slug_by_sha256(
pool: &PgPool,
sha256: &str,
) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar(
"SELECT slug FROM analyses WHERE sha256 = $1",
)
.bind(sha256)
.fetch_optional(pool)
.await
}
pub async fn find_by_slug(
pool: &PgPool,
slug: &str,
) -> Result<Option<AnalysisRow>, sqlx::Error> {
sqlx::query_as::<_, AnalysisRow>(
"SELECT * FROM analyses WHERE slug = $1",
)
.bind(slug)
.fetch_optional(pool)
.await
}
pub async fn find_pass_results(
pool: &PgPool,
analysis_id: Uuid,
) -> Result<Vec<PassResultRow>, sqlx::Error> {
sqlx::query_as::<_, PassResultRow>(
"SELECT * FROM pass_results \
WHERE analysis_id = $1 \
ORDER BY pass_name",
)
.bind(analysis_id)
.fetch_all(pool)
.await
}
pub async fn insert_analysis(
tx: &mut Transaction<'_, Postgres>,
new: &NewAnalysis,
) -> Result<AnalysisRow, sqlx::Error> {
sqlx::query_as::<_, AnalysisRow>(
"INSERT INTO analyses \
(sha256, file_name, file_size, format, \
architecture, entry_point, threat_score, \
risk_level, slug) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
RETURNING *",
)
.bind(&new.sha256)
.bind(&new.file_name)
.bind(new.file_size)
.bind(&new.format)
.bind(&new.architecture)
.bind(new.entry_point)
.bind(new.threat_score)
.bind(&new.risk_level)
.bind(&new.slug)
.fetch_one(tx.as_mut())
.await
}
pub async fn insert_pass_result(
tx: &mut Transaction<'_, Postgres>,
new: &NewPassResult,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO pass_results \
(analysis_id, pass_name, result, duration_ms) \
VALUES ($1, $2, $3, $4)",
)
.bind(new.analysis_id)
.bind(&new.pass_name)
.bind(&new.result)
.bind(new.duration_ms)
.execute(tx.as_mut())
.await?;
Ok(())
}

View File

@ -0,0 +1,100 @@
// ©AngelaMos | 2026
// error.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
#[derive(Serialize)]
struct ErrorBody {
error: ErrorDetail,
}
#[derive(Serialize)]
struct ErrorDetail {
code: &'static str,
message: String,
}
pub enum ApiError {
NoFile,
FileTooLarge { max_bytes: usize },
InvalidBinary { reason: String },
AnalysisFailed { reason: String },
NotFound { resource: String },
Internal { reason: String },
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
Self::Internal {
reason: e.to_string(),
}
}
}
impl From<serde_json::Error> for ApiError {
fn from(e: serde_json::Error) -> Self {
Self::Internal {
reason: e.to_string(),
}
}
}
impl From<tokio::task::JoinError> for ApiError {
fn from(e: tokio::task::JoinError) -> Self {
Self::Internal {
reason: e.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code, message) = match self {
Self::NoFile => (
StatusCode::BAD_REQUEST,
"NO_FILE",
"No file was provided in the upload"
.to_string(),
),
Self::FileTooLarge { max_bytes } => (
StatusCode::BAD_REQUEST,
"FILE_TOO_LARGE",
format!(
"File exceeds maximum allowed size of {} bytes",
max_bytes
),
),
Self::InvalidBinary { reason } => (
StatusCode::BAD_REQUEST,
"INVALID_BINARY",
reason,
),
Self::AnalysisFailed { reason } => (
StatusCode::INTERNAL_SERVER_ERROR,
"ANALYSIS_FAILED",
reason,
),
Self::NotFound { resource } => (
StatusCode::NOT_FOUND,
"NOT_FOUND",
format!("{resource} not found"),
),
Self::Internal { reason } => (
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL",
reason,
),
};
(
status,
Json(ErrorBody {
error: ErrorDetail { code, message },
}),
)
.into_response()
}
}

View File

@ -0,0 +1,95 @@
// ©AngelaMos | 2026
// main.rs
mod config;
mod db;
mod error;
mod middleware;
mod routes;
mod state;
use std::sync::Arc;
use anyhow::Context;
use axum::extract::DefaultBodyLimit;
use clap::Parser;
use sqlx::postgres::PgPoolOptions;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use config::AppConfig;
use state::AppState;
const DB_MAX_CONNECTIONS: u32 = 20;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = AppConfig::parse();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
EnvFilter::new(
"info,tower_http=debug",
)
}),
)
.init();
let db = PgPoolOptions::new()
.max_connections(DB_MAX_CONNECTIONS)
.connect(&config.database_url)
.await
.context("failed to connect to database")?;
db::run_migrations(&db)
.await
.context("failed to run database migrations")?;
let engine = axumortem_engine::AnalysisEngine::new()
.context(
"failed to initialize analysis engine",
)?;
let config = Arc::new(config);
let state = AppState {
db,
engine: Arc::new(engine),
config: Arc::clone(&config),
};
let layers = ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(middleware::cors::layer(&config))
.layer(DefaultBodyLimit::max(
config.max_upload_size,
));
let app =
routes::api_router().layer(layers).with_state(state);
let bind_address = config.bind_address();
let listener =
tokio::net::TcpListener::bind(&bind_address)
.await
.context("failed to bind TCP listener")?;
tracing::info!("listening on {}", bind_address);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server error")?;
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to install ctrl+c handler");
tracing::info!("shutdown signal received");
}

View File

@ -0,0 +1,29 @@
// ©AngelaMos | 2026
// cors.rs
use axum::http::header::{HeaderName, ACCEPT, CONTENT_TYPE};
use axum::http::Method;
use tower_http::cors::{Any, CorsLayer};
use crate::config::AppConfig;
const ALLOWED_METHODS: [Method; 3] =
[Method::GET, Method::POST, Method::OPTIONS];
const ALLOWED_HEADERS: [HeaderName; 2] =
[CONTENT_TYPE, ACCEPT];
pub fn layer(config: &AppConfig) -> CorsLayer {
let base = CorsLayer::new()
.allow_methods(ALLOWED_METHODS)
.allow_headers(ALLOWED_HEADERS);
if config.cors_origin == "*" {
base.allow_origin(Any)
} else {
base.allow_origin([config
.cors_origin
.parse()
.expect("invalid CORS origin header value")])
}
}

View File

@ -0,0 +1,4 @@
// ©AngelaMos | 2026
// mod.rs
pub mod cors;

View File

@ -0,0 +1,66 @@
// ©AngelaMos | 2026
// analysis.rs
use std::collections::HashMap;
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;
use crate::db::queries;
use crate::error::ApiError;
use crate::state::AppState;
#[derive(Serialize)]
pub(crate) struct AnalysisResponse {
id: Uuid,
sha256: String,
file_name: String,
file_size: i64,
format: String,
architecture: String,
entry_point: Option<i64>,
threat_score: Option<i32>,
risk_level: Option<String>,
slug: String,
created_at: DateTime<Utc>,
passes: HashMap<String, serde_json::Value>,
}
pub async fn get_by_slug(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<AnalysisResponse>, ApiError> {
let row = queries::find_by_slug(&state.db, &slug)
.await?
.ok_or_else(|| ApiError::NotFound {
resource: format!("analysis '{slug}'"),
})?;
let pass_rows =
queries::find_pass_results(&state.db, row.id)
.await?;
let passes: HashMap<String, serde_json::Value> =
pass_rows
.into_iter()
.map(|p| (p.pass_name, p.result))
.collect();
Ok(Json(AnalysisResponse {
id: row.id,
sha256: row.sha256,
file_name: row.file_name,
file_size: row.file_size,
format: row.format,
architecture: row.architecture,
entry_point: row.entry_point,
threat_score: row.threat_score,
risk_level: row.risk_level,
slug: row.slug,
created_at: row.created_at,
passes,
}))
}

View File

@ -0,0 +1,30 @@
// ©AngelaMos | 2026
// health.rs
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::state::AppState;
#[derive(Serialize)]
pub(crate) struct HealthResponse {
status: &'static str,
database: &'static str,
}
pub async fn check(
State(state): State<AppState>,
) -> Json<HealthResponse> {
let db_status =
match sqlx::query("SELECT 1").execute(&state.db).await
{
Ok(_) => "connected",
Err(_) => "disconnected",
};
Json(HealthResponse {
status: "ok",
database: db_status,
})
}

View File

@ -0,0 +1,21 @@
// ©AngelaMos | 2026
// mod.rs
mod analysis;
mod health;
mod upload;
use axum::routing::{get, post};
use axum::Router;
use crate::state::AppState;
pub fn api_router() -> Router<AppState> {
Router::new()
.route("/api/health", get(health::check))
.route("/api/upload", post(upload::handle))
.route(
"/api/analysis/{slug}",
get(analysis::get_by_slug),
)
}

View File

@ -0,0 +1,180 @@
// ©AngelaMos | 2026
// upload.rs
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Multipart, State};
use axum::Json;
use serde::Serialize;
use uuid::Uuid;
use axumortem_engine::context::AnalysisContext;
use axumortem_engine::pass::PassReport;
use crate::db::models::{NewAnalysis, NewPassResult};
use crate::db::queries;
use crate::error::ApiError;
use crate::state::AppState;
const SLUG_LENGTH: usize = 12;
const PASS_NAME_MAP: &[(&str, &str)] = &[
("disasm", "disassembly"),
];
#[derive(Serialize)]
pub(crate) struct UploadResponse {
slug: String,
cached: bool,
}
pub async fn handle(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, ApiError> {
let (file_name, data) =
extract_file(&mut multipart).await?;
let sha256 =
axumortem_engine::sha256_hex(&data);
if let Some(slug) =
queries::find_slug_by_sha256(
&state.db, &sha256,
)
.await?
{
return Ok(Json(UploadResponse {
slug,
cached: true,
}));
}
let engine = Arc::clone(&state.engine);
let name_clone = file_name.clone();
let (ctx, report) =
tokio::task::spawn_blocking(move || {
engine.analyze(&data, &name_clone)
})
.await?;
let fmt = ctx.format_result.as_ref();
let threat = ctx.threat_result.as_ref();
let slug = sha256[..SLUG_LENGTH].to_string();
let new_analysis = NewAnalysis {
sha256,
file_name,
file_size: ctx.file_size as i64,
format: fmt
.map(|f| f.format.to_string())
.unwrap_or_default(),
architecture: fmt
.map(|f| f.architecture.to_string())
.unwrap_or_default(),
entry_point: fmt
.map(|f| f.entry_point as i64),
threat_score: threat
.map(|t| t.total_score as i32),
risk_level: threat
.map(|t| t.risk_level.to_string()),
slug: slug.clone(),
};
let mut tx = state.db.begin().await?;
let row =
queries::insert_analysis(&mut tx, &new_analysis)
.await?;
let pass_results =
build_pass_results(&ctx, &report, row.id)?;
for pr in &pass_results {
queries::insert_pass_result(&mut tx, pr).await?;
}
tx.commit().await?;
Ok(Json(UploadResponse {
slug,
cached: false,
}))
}
async fn extract_file(
multipart: &mut Multipart,
) -> Result<(String, Vec<u8>), ApiError> {
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError::Internal {
reason: e.to_string(),
})?
{
if field.name() == Some("file") {
let name = field
.file_name()
.unwrap_or("unknown")
.to_string();
let data = field
.bytes()
.await
.map_err(|e| ApiError::Internal {
reason: e.to_string(),
})?;
return Ok((name, data.to_vec()));
}
}
Err(ApiError::NoFile)
}
fn api_name(engine_name: &str) -> &str {
for &(from, to) in PASS_NAME_MAP {
if engine_name == from {
return to;
}
}
engine_name
}
fn build_pass_results(
ctx: &AnalysisContext,
report: &PassReport,
analysis_id: Uuid,
) -> Result<Vec<NewPassResult>, serde_json::Error> {
let durations: HashMap<&str, u64> = report
.outcomes
.iter()
.map(|o| (o.name, o.duration_ms))
.collect();
let mut results = Vec::new();
macro_rules! add_pass {
($field:ident, $name:expr) => {
if let Some(ref r) = ctx.$field {
results.push(NewPassResult {
analysis_id,
pass_name: api_name($name)
.to_string(),
result: serde_json::to_value(r)?,
duration_ms: durations
.get($name)
.map(|&d| d as i32),
});
}
};
}
add_pass!(format_result, "format");
add_pass!(import_result, "imports");
add_pass!(string_result, "strings");
add_pass!(entropy_result, "entropy");
add_pass!(disassembly_result, "disasm");
add_pass!(threat_result, "threat");
Ok(results)
}

View File

@ -0,0 +1,16 @@
// ©AngelaMos | 2026
// state.rs
use std::sync::Arc;
use axumortem_engine::AnalysisEngine;
use sqlx::PgPool;
use crate::config::AppConfig;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub engine: Arc<AnalysisEngine>,
pub config: Arc<AppConfig>,
}

View File

@ -0,0 +1,27 @@
# =============================================================================
# AngelaMos | 2026
# cloudflared.compose.yml
# =============================================================================
# Cloudflare Tunnel for production remote access
# Usage: docker compose -f compose.yml -f cloudflared.compose.yml up -d
# =============================================================================
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: ${APP_NAME:-axumortem}-tunnel
command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN}
networks:
- app
depends_on:
nginx:
condition: service_started
deploy:
resources:
limits:
cpus: '0.5'
memory: 128M
reservations:
cpus: '0.1'
memory: 32M
restart: unless-stopped

View File

@ -0,0 +1,92 @@
# ©AngelaMos | 2026
# compose.yml
# Production compose — Nginx serves frontend + proxies /api/* to backend
# For Cloudflare tunnel: docker compose -f compose.yml -f cloudflared.compose.yml up
name: ${APP_NAME:-axumortem}
services:
nginx:
build:
context: .
dockerfile: infra/docker/vite.prod
args:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-axumortem}
container_name: ${APP_NAME:-axumortem}-nginx
ports:
- "${NGINX_HOST_PORT:-22784}:80"
depends_on:
backend:
condition: service_started
networks:
- app
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
reservations:
cpus: '0.25'
memory: 64M
restart: unless-stopped
backend:
build:
context: .
dockerfile: infra/docker/rust.prod
container_name: ${APP_NAME:-axumortem}-backend
environment:
- DATABASE_URL=postgres://axumortem:${POSTGRES_PASSWORD:-axumortem}@postgres:5432/axumortem
- RUST_LOG=${RUST_LOG:-info}
- HOST=0.0.0.0
- PORT=3000
- MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE:-52428800}
- CORS_ORIGIN=${CORS_ORIGIN:-*}
depends_on:
postgres:
condition: service_healthy
networks:
- app
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
restart: unless-stopped
postgres:
image: postgres:18-alpine
container_name: ${APP_NAME:-axumortem}-postgres
environment:
- POSTGRES_USER=axumortem
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-axumortem}
- POSTGRES_DB=axumortem
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U axumortem"]
interval: 10s
timeout: 3s
retries: 5
networks:
- app
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
restart: unless-stopped
networks:
app:
driver: bridge
volumes:
pgdata:

View File

@ -0,0 +1,95 @@
# ©AngelaMos | 2026
# dev.compose.yml
# Development compose — Nginx + Vite + Rust backend + PostgreSQL
name: ${APP_NAME:-axumortem}-dev
services:
nginx:
image: nginx:1.29-alpine
container_name: ${APP_NAME:-axumortem}-nginx-dev
ports:
- "${NGINX_HOST_PORT:-58495}:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
depends_on:
frontend:
condition: service_started
backend:
condition: service_started
networks:
- app
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: ../infra/docker/vite.dev
container_name: ${APP_NAME:-axumortem}-frontend-dev
ports:
- "${FRONTEND_HOST_PORT:-15723}:5173"
volumes:
- ./frontend:/app
- frontend_modules:/app/node_modules
environment:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-axumortem}
networks:
- app
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: ../infra/docker/rust.dev
container_name: ${APP_NAME:-axumortem}-backend-dev
ports:
- "${BACKEND_HOST_PORT:-36968}:3000"
volumes:
- ./backend:/app
- cargo_registry:/usr/local/cargo/registry
- cargo_target:/app/target
environment:
- DATABASE_URL=postgres://axumortem:axumortem@postgres:5432/axumortem
- RUST_LOG=${RUST_LOG:-info,tower_http=debug}
- HOST=0.0.0.0
- PORT=3000
- MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE:-52428800}
- CORS_ORIGIN=*
depends_on:
postgres:
condition: service_healthy
networks:
- app
restart: unless-stopped
postgres:
image: postgres:18-alpine
container_name: ${APP_NAME:-axumortem}-postgres-dev
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
environment:
- POSTGRES_USER=axumortem
- POSTGRES_PASSWORD=axumortem
- POSTGRES_DB=axumortem
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U axumortem"]
interval: 5s
timeout: 3s
retries: 5
networks:
- app
restart: unless-stopped
networks:
app:
driver: bridge
volumes:
frontend_modules:
cargo_registry:
cargo_target:
pgdata:

View File

@ -0,0 +1,15 @@
node_modules
build
dist
.git
.gitignore
*.md
.env*
.vscode
.idea
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
coverage
.nyc_output

View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2025
# .stylelintignore
# Dependencies
node_modules/
# Production builds
dist/
build/
out/
# JS/TS files
**/*.js
**/*.jsx
**/*.ts
**/*.tsx
# Generated files
*.min.css
# Error system styles - ignore from linting
src/core/app/_toastStyles.scss

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 82,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": { "maxAllowedComplexity": 25 }
},
"noForEach": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"noUndeclaredVariables": "error"
},
"style": {
"useImportType": "error",
"useConst": "error",
"useTemplate": "error",
"useSelfClosingElements": "error",
"useFragmentSyntax": "error",
"noNonNullAssertion": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "shorthand" }
},
"useNamingConvention": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": "warn",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<!--
Binary Analysis Tool
Author(s): © AngelaMos, CarterPerez-dev
-->
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="/assets/favicon.ico"
/>
<link
rel="apple-touch-icon"
type="image/png"
href="/assets/apple-touch-icon.png"
/>
<link
rel="manifest"
href="/assets/site.webmanifest"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Binary Analysis Tool</title>
<meta
name="description"
content="*Cracked*"
/>
<meta
name="author"
content=" ©AngelaMos | 2026 | CarterPerez-dev"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,51 @@
{
"name": "binary-analysis-tool",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.0",
"react": "^19.2.1",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-icon": "^1.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.1.1",
"sonner": "^2.0.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"sass": "^1.95.0",
"stylelint": "^16.26.1",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5",
"vite-tsconfig-paths": "^5.1.0"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,36 @@
// ===========================
// ©AngelaMos | 2026
// App.tsx
// ===========================
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { queryClient } from '@/core/api'
import { router } from '@/core/app/routers'
import '@/core/app/toast.module.scss'
export default function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<RouterProvider router={router} />
<Toaster
position="top-right"
duration={2000}
theme="dark"
toastOptions={{
style: {
background: 'hsl(0, 0%, 12.2%)',
border: '1px solid hsl(0, 0%, 18%)',
color: 'hsl(0, 0%, 98%)',
},
}}
/>
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,48 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
import { useMutation, useQuery } from '@tanstack/react-query'
import type { AxiosError } from 'axios'
import { API_ENDPOINTS, QUERY_KEYS, UPLOAD_TIMEOUT_MS } from '@/config'
import { apiClient } from '@/core/api'
import { transformAxiosError } from '@/core/api/errors'
import { AnalysisResponseSchema, UploadResponseSchema } from '../schemas'
import type { ApiErrorBody, UploadResponse } from '../types'
export function useUpload() {
return useMutation<
UploadResponse,
ReturnType<typeof transformAxiosError>,
File
>({
mutationFn: async (file: File) => {
const form = new FormData()
form.append('file', file)
const { data } = await apiClient.post(API_ENDPOINTS.UPLOAD, form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: UPLOAD_TIMEOUT_MS,
})
return UploadResponseSchema.parse(data)
},
onError: (error) => {
return transformAxiosError(error as unknown as AxiosError<ApiErrorBody>)
},
})
}
export function useAnalysis(slug: string) {
return useQuery({
queryKey: QUERY_KEYS.ANALYSIS.BY_SLUG(slug),
queryFn: async () => {
const { data } = await apiClient.get(API_ENDPOINTS.ANALYSIS(slug))
return AnalysisResponseSchema.parse(data)
},
enabled: slug.length > 0,
staleTime: Infinity,
refetchOnWindowFocus: false,
})
}

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './hooks'
export * from './schemas'
export * from './types'

View File

@ -0,0 +1,397 @@
// ===================
// © AngelaMos | 2026
// schemas.ts
// ===================
import { z } from 'zod'
export const BinaryFormatSchema = z.enum(['Elf', 'Pe', 'MachO'])
export const ArchitectureSchema = z.union([
z.literal(['X86', 'X86_64', 'Arm', 'Aarch64']),
z.object({ Other: z.string() }),
])
export const EndiannessSchema = z.enum(['Little', 'Big'])
export const RiskLevelSchema = z.enum([
'Benign',
'Low',
'Medium',
'High',
'Critical',
])
export const SeveritySchema = z.enum(['Low', 'Medium', 'High', 'Critical'])
export const StringEncodingSchema = z.enum(['Ascii', 'Utf8', 'Utf16Le'])
export const StringCategorySchema = z.enum([
'Url',
'IpAddress',
'FilePath',
'RegistryKey',
'ShellCommand',
'CryptoWallet',
'Email',
'SuspiciousApi',
'PackerSignature',
'DebugArtifact',
'AntiAnalysis',
'PersistencePath',
'EncodedData',
'Generic',
])
export const EntropyClassificationSchema = z.enum([
'Plaintext',
'NativeCode',
'Compressed',
'Packed',
'Encrypted',
])
export const EntropyFlagSchema = z.enum([
'HighEntropy',
'HighVirtualToRawRatio',
'EmptyRawData',
'Rwx',
'PackerSectionName',
])
export const FlowControlTypeSchema = z.enum([
'Next',
'Branch',
'ConditionalBranch',
'Call',
'Return',
'Interrupt',
])
export const CfgEdgeTypeSchema = z.enum([
'Fallthrough',
'ConditionalTrue',
'ConditionalFalse',
'Unconditional',
'Call',
])
export const SectionPermissionsSchema = z.object({
read: z.boolean(),
write: z.boolean(),
execute: z.boolean(),
})
export const SectionInfoSchema = z.object({
name: z.string(),
virtual_address: z.number(),
virtual_size: z.number(),
raw_offset: z.number(),
raw_size: z.number(),
permissions: SectionPermissionsSchema,
sha256: z.string(),
})
export const SegmentInfoSchema = z.object({
name: z.string().nullable(),
virtual_address: z.number(),
virtual_size: z.number(),
file_offset: z.number(),
file_size: z.number(),
permissions: SectionPermissionsSchema,
})
export const FormatAnomalySchema = z.union([
z.string(),
z.record(z.string(), z.unknown()),
])
export const PeDllCharacteristicsSchema = z.object({
aslr: z.boolean(),
dep: z.boolean(),
cfg: z.boolean(),
no_seh: z.boolean(),
force_integrity: z.boolean(),
})
export const PeInfoSchema = z.object({
image_base: z.number(),
subsystem: z.string(),
dll_characteristics: PeDllCharacteristicsSchema,
timestamp: z.number(),
linker_version: z.string(),
tls_callback_count: z.number(),
has_overlay: z.boolean(),
overlay_size: z.number(),
rich_header_present: z.boolean(),
})
export const ElfInfoSchema = z.object({
os_abi: z.string(),
elf_type: z.string(),
interpreter: z.string().nullable(),
gnu_relro: z.boolean(),
bind_now: z.boolean(),
stack_executable: z.boolean(),
needed_libraries: z.array(z.string()),
})
export const MachOInfoSchema = z.object({
file_type: z.string(),
cpu_subtype: z.string(),
is_universal: z.boolean(),
has_code_signature: z.boolean(),
min_os_version: z.string().nullable(),
sdk_version: z.string().nullable(),
dylibs: z.array(z.string()),
has_function_starts: z.boolean(),
})
export const FormatResultSchema = z.object({
format: BinaryFormatSchema,
architecture: ArchitectureSchema,
bits: z.number(),
endianness: EndiannessSchema,
entry_point: z.number(),
is_stripped: z.boolean(),
is_pie: z.boolean(),
has_debug_info: z.boolean(),
sections: z.array(SectionInfoSchema),
segments: z.array(SegmentInfoSchema),
anomalies: z.array(FormatAnomalySchema),
pe_info: PeInfoSchema.nullable(),
elf_info: ElfInfoSchema.nullable(),
macho_info: MachOInfoSchema.nullable(),
function_hints: z.array(z.number()).default([]),
})
export const ImportEntrySchema = z.object({
library: z.string(),
function: z.string(),
address: z.number().nullable(),
ordinal: z.number().nullable(),
is_suspicious: z.boolean(),
threat_tags: z.array(z.string()),
})
export const ExportEntrySchema = z.object({
name: z.string().nullable(),
address: z.number(),
ordinal: z.number().nullable(),
is_forwarded: z.boolean(),
forward_target: z.string().nullable(),
})
export const SuspiciousCombinationSchema = z.object({
name: z.string(),
description: z.string(),
apis: z.array(z.string()),
mitre_id: z.string(),
severity: SeveritySchema,
})
export const ImportMitreMappingSchema = z.object({
technique_id: z.string(),
api: z.string(),
tag: z.string(),
})
export const ImportStatisticsSchema = z.object({
total_imports: z.number(),
total_exports: z.number(),
suspicious_count: z.number(),
library_count: z.number(),
})
export const ImportResultSchema = z.object({
imports: z.array(ImportEntrySchema),
exports: z.array(ExportEntrySchema),
libraries: z.array(z.string()),
suspicious_combinations: z.array(SuspiciousCombinationSchema),
mitre_mappings: z.array(ImportMitreMappingSchema),
statistics: ImportStatisticsSchema,
})
export const ExtractedStringSchema = z.object({
value: z.string(),
offset: z.number(),
encoding: StringEncodingSchema,
length: z.number(),
category: StringCategorySchema,
is_suspicious: z.boolean(),
section: z.string().nullable(),
})
export const StringStatisticsSchema = z.object({
total: z.number(),
by_encoding: z.record(z.string(), z.number()),
by_category: z.record(z.string(), z.number()),
suspicious_count: z.number(),
})
export const StringResultSchema = z.object({
strings: z.array(ExtractedStringSchema),
statistics: StringStatisticsSchema,
})
export const SectionEntropySchema = z.object({
name: z.string(),
entropy: z.number(),
size: z.number(),
classification: EntropyClassificationSchema,
virtual_to_raw_ratio: z.number(),
is_anomalous: z.boolean(),
flags: z.array(EntropyFlagSchema),
})
export const PackingIndicatorSchema = z.object({
indicator_type: z.string(),
description: z.string(),
evidence: z.string(),
packer_name: z.string().nullable(),
})
export const EntropyResultSchema = z.object({
overall_entropy: z.number(),
sections: z.array(SectionEntropySchema),
packing_detected: z.boolean(),
packer_name: z.string().nullable(),
packing_indicators: z.array(PackingIndicatorSchema),
})
export const InstructionInfoSchema = z.object({
address: z.number(),
bytes: z.array(z.number()),
mnemonic: z.string(),
operands: z.string(),
size: z.number(),
flow_control: FlowControlTypeSchema,
})
export const BasicBlockInfoSchema = z.object({
start_address: z.number(),
end_address: z.number(),
instruction_count: z.number(),
instructions: z.array(InstructionInfoSchema),
successors: z.array(z.number()),
predecessors: z.array(z.number()),
})
export const CfgNodeSchema = z.object({
id: z.number(),
label: z.string(),
instruction_count: z.number(),
instructions_preview: z.string(),
})
export const CfgEdgeSchema = z.object({
from: z.number(),
to: z.number(),
edge_type: CfgEdgeTypeSchema,
})
export const FunctionCfgSchema = z.object({
nodes: z.array(CfgNodeSchema),
edges: z.array(CfgEdgeSchema),
})
export const FunctionInfoSchema = z.object({
address: z.number(),
name: z.string().nullable(),
size: z.number(),
instruction_count: z.number(),
basic_blocks: z.array(BasicBlockInfoSchema),
is_entry_point: z.boolean(),
cfg: FunctionCfgSchema,
})
export const DisassemblyResultSchema = z.object({
functions: z.array(FunctionInfoSchema),
total_instructions: z.number(),
total_functions: z.number(),
architecture_bits: z.number(),
entry_function_address: z.number(),
})
export const ScoringDetailSchema = z.object({
rule: z.string(),
points: z.number(),
evidence: z.string(),
})
export const ScoringCategorySchema = z.object({
name: z.string(),
score: z.number(),
max_score: z.number(),
details: z.array(ScoringDetailSchema),
})
export const ThreatMitreMappingSchema = z.object({
technique_id: z.string(),
technique_name: z.string(),
tactic: z.string(),
evidence: z.string(),
})
export const YaraMetadataSchema = z.object({
description: z.string().nullable(),
category: z.string().nullable(),
severity: z.string().nullable(),
})
export const YaraStringMatchSchema = z.object({
identifier: z.string(),
match_count: z.number(),
})
export const YaraMatchSchema = z.object({
rule_name: z.string(),
tags: z.array(z.string()),
metadata: YaraMetadataSchema,
matched_strings: z.array(YaraStringMatchSchema),
})
export const ThreatResultSchema = z.object({
total_score: z.number(),
risk_level: RiskLevelSchema,
categories: z.array(ScoringCategorySchema),
mitre_techniques: z.array(ThreatMitreMappingSchema),
yara_matches: z.array(YaraMatchSchema),
summary: z.string(),
})
export const AnalysisPassesSchema = z.object({
format: FormatResultSchema.optional(),
imports: ImportResultSchema.optional(),
strings: StringResultSchema.optional(),
entropy: EntropyResultSchema.optional(),
disassembly: DisassemblyResultSchema.optional(),
threat: ThreatResultSchema.optional(),
})
export const AnalysisResponseSchema = z.object({
id: z.string(),
sha256: z.string(),
file_name: z.string(),
file_size: z.number(),
format: z.string(),
architecture: z.string(),
entry_point: z.number().nullable(),
threat_score: z.number().nullable(),
risk_level: z.string().nullable(),
slug: z.string(),
created_at: z.string(),
passes: AnalysisPassesSchema,
})
export const UploadResponseSchema = z.object({
slug: z.string(),
cached: z.boolean(),
})
export const ApiErrorBodySchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
}),
})

View File

@ -0,0 +1,116 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
import type { z } from 'zod'
import type {
AnalysisPassesSchema,
AnalysisResponseSchema,
ApiErrorBodySchema,
ArchitectureSchema,
BasicBlockInfoSchema,
BinaryFormatSchema,
CfgEdgeSchema,
CfgEdgeTypeSchema,
CfgNodeSchema,
DisassemblyResultSchema,
ElfInfoSchema,
EndiannessSchema,
EntropyClassificationSchema,
EntropyFlagSchema,
EntropyResultSchema,
ExportEntrySchema,
ExtractedStringSchema,
FlowControlTypeSchema,
FormatAnomalySchema,
FormatResultSchema,
FunctionCfgSchema,
FunctionInfoSchema,
ImportEntrySchema,
ImportMitreMappingSchema,
ImportResultSchema,
ImportStatisticsSchema,
InstructionInfoSchema,
MachOInfoSchema,
PackingIndicatorSchema,
PeDllCharacteristicsSchema,
PeInfoSchema,
RiskLevelSchema,
ScoringCategorySchema,
ScoringDetailSchema,
SectionEntropySchema,
SectionInfoSchema,
SectionPermissionsSchema,
SegmentInfoSchema,
SeveritySchema,
StringCategorySchema,
StringEncodingSchema,
StringResultSchema,
StringStatisticsSchema,
SuspiciousCombinationSchema,
ThreatMitreMappingSchema,
ThreatResultSchema,
UploadResponseSchema,
YaraMatchSchema,
YaraMetadataSchema,
YaraStringMatchSchema,
} from '../schemas'
export type BinaryFormat = z.infer<typeof BinaryFormatSchema>
export type Architecture = z.infer<typeof ArchitectureSchema>
export type Endianness = z.infer<typeof EndiannessSchema>
export type RiskLevel = z.infer<typeof RiskLevelSchema>
export type Severity = z.infer<typeof SeveritySchema>
export type StringEncoding = z.infer<typeof StringEncodingSchema>
export type StringCategory = z.infer<typeof StringCategorySchema>
export type EntropyClassification = z.infer<typeof EntropyClassificationSchema>
export type EntropyFlag = z.infer<typeof EntropyFlagSchema>
export type FlowControlType = z.infer<typeof FlowControlTypeSchema>
export type CfgEdgeType = z.infer<typeof CfgEdgeTypeSchema>
export type SectionPermissions = z.infer<typeof SectionPermissionsSchema>
export type SectionInfo = z.infer<typeof SectionInfoSchema>
export type SegmentInfo = z.infer<typeof SegmentInfoSchema>
export type FormatAnomaly = z.infer<typeof FormatAnomalySchema>
export type PeDllCharacteristics = z.infer<typeof PeDllCharacteristicsSchema>
export type PeInfo = z.infer<typeof PeInfoSchema>
export type ElfInfo = z.infer<typeof ElfInfoSchema>
export type MachOInfo = z.infer<typeof MachOInfoSchema>
export type FormatResult = z.infer<typeof FormatResultSchema>
export type ImportEntry = z.infer<typeof ImportEntrySchema>
export type ExportEntry = z.infer<typeof ExportEntrySchema>
export type SuspiciousCombination = z.infer<typeof SuspiciousCombinationSchema>
export type ImportMitreMapping = z.infer<typeof ImportMitreMappingSchema>
export type ImportStatistics = z.infer<typeof ImportStatisticsSchema>
export type ImportResult = z.infer<typeof ImportResultSchema>
export type ExtractedString = z.infer<typeof ExtractedStringSchema>
export type StringStatistics = z.infer<typeof StringStatisticsSchema>
export type StringResult = z.infer<typeof StringResultSchema>
export type SectionEntropy = z.infer<typeof SectionEntropySchema>
export type PackingIndicator = z.infer<typeof PackingIndicatorSchema>
export type EntropyResult = z.infer<typeof EntropyResultSchema>
export type InstructionInfo = z.infer<typeof InstructionInfoSchema>
export type BasicBlockInfo = z.infer<typeof BasicBlockInfoSchema>
export type CfgNode = z.infer<typeof CfgNodeSchema>
export type CfgEdge = z.infer<typeof CfgEdgeSchema>
export type FunctionCfg = z.infer<typeof FunctionCfgSchema>
export type FunctionInfo = z.infer<typeof FunctionInfoSchema>
export type DisassemblyResult = z.infer<typeof DisassemblyResultSchema>
export type ScoringDetail = z.infer<typeof ScoringDetailSchema>
export type ScoringCategory = z.infer<typeof ScoringCategorySchema>
export type ThreatMitreMapping = z.infer<typeof ThreatMitreMappingSchema>
export type YaraMetadata = z.infer<typeof YaraMetadataSchema>
export type YaraStringMatch = z.infer<typeof YaraStringMatchSchema>
export type YaraMatch = z.infer<typeof YaraMatchSchema>
export type ThreatResult = z.infer<typeof ThreatResultSchema>
export type AnalysisPasses = z.infer<typeof AnalysisPassesSchema>
export type AnalysisResponse = z.infer<typeof AnalysisResponseSchema>
export type UploadResponse = z.infer<typeof UploadResponseSchema>
export type ApiErrorBody = z.infer<typeof ApiErrorBodySchema>

View File

@ -0,0 +1,76 @@
// ===================
// © AngelaMos | 2026
// config.ts
// ===================
export const API_ENDPOINTS = {
UPLOAD: '/upload',
ANALYSIS: (slug: string) => `/analysis/${slug}`,
HEALTH: '/health',
} as const
export const QUERY_KEYS = {
ANALYSIS: {
BY_SLUG: (slug: string) => ['analysis', slug] as const,
},
} as const
export const ROUTES = {
HOME: '/',
ANALYSIS: '/analysis/:slug',
} as const
export const STORAGE_KEYS = {
UI: 'ui-storage',
} as const
export const QUERY_CONFIG = {
STALE_TIME: {
USER: 1000 * 60 * 5,
STATIC: Infinity,
FREQUENT: 1000 * 30,
},
GC_TIME: {
DEFAULT: 1000 * 60 * 30,
LONG: 1000 * 60 * 60,
},
RETRY: {
DEFAULT: 3,
NONE: 0,
},
} as const
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER: 500,
} as const
export const UPLOAD_TIMEOUT_MS = 120_000
export const RISK_LEVEL_COLORS: Record<string, string> = {
Benign: '#22c55e',
Low: '#84cc16',
Medium: '#eab308',
High: '#f97316',
Critical: '#ef4444',
} as const
export const ENTROPY_CLASSIFICATION_COLORS: Record<string, string> = {
Plaintext: '#22c55e',
NativeCode: '#3b82f6',
Compressed: '#eab308',
Packed: '#f97316',
Encrypted: '#ef4444',
} as const
export type ApiEndpoint = typeof API_ENDPOINTS
export type QueryKey = typeof QUERY_KEYS
export type Route = typeof ROUTES

View File

@ -0,0 +1,17 @@
// ===================
// © AngelaMos | 2026
// api.config.ts
// ===================
import axios, { type AxiosInstance } from 'axios'
const getBaseURL = (): string => {
return import.meta.env.VITE_API_URL ?? '/api'
}
export const apiClient: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
})

View File

@ -0,0 +1,114 @@
/**
* ©AngelaMos | 2026
* errors.ts
*/
import type { AxiosError } from 'axios'
export const ApiErrorCode = {
NETWORK_ERROR: 'NETWORK_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
} as const
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
export class ApiError extends Error {
readonly code: ApiErrorCode
readonly statusCode: number
readonly details?: Record<string, string[]>
constructor(
message: string,
code: ApiErrorCode,
statusCode: number,
details?: Record<string, string[]>
) {
super(message)
this.name = 'ApiError'
this.code = code
this.statusCode = statusCode
this.details = details
}
getUserMessage(): string {
const messages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NETWORK_ERROR]:
'Unable to connect. Please check your internet connection.',
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
[ApiErrorCode.AUTHENTICATION_ERROR]:
'Your session has expired. Please log in again.',
[ApiErrorCode.AUTHORIZATION_ERROR]:
'You do not have permission to perform this action.',
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
[ApiErrorCode.CONFLICT]:
'This operation conflicts with an existing resource.',
[ApiErrorCode.RATE_LIMITED]:
'Too many requests. Please wait a moment and try again.',
[ApiErrorCode.SERVER_ERROR]:
'Something went wrong on our end. Please try again later.',
[ApiErrorCode.UNKNOWN_ERROR]:
'An unexpected error occurred. Please try again.',
}
return messages[this.code]
}
}
interface ApiErrorResponse {
detail?: string | { msg: string; type: string }[]
message?: string
}
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
if (!error.response) {
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
}
const { status } = error.response
const data = error.response.data as ApiErrorResponse | undefined
let message = 'An error occurred'
let details: Record<string, string[]> | undefined
if (data?.detail) {
if (typeof data.detail === 'string') {
message = data.detail
} else if (Array.isArray(data.detail)) {
details = { validation: [] }
data.detail.forEach((err) => {
details?.validation.push(err.msg)
})
message = 'Validation error'
}
} else if (data?.message) {
message = data.message
}
const codeMap: Record<number, ApiErrorCode> = {
400: ApiErrorCode.VALIDATION_ERROR,
401: ApiErrorCode.AUTHENTICATION_ERROR,
403: ApiErrorCode.AUTHORIZATION_ERROR,
404: ApiErrorCode.NOT_FOUND,
409: ApiErrorCode.CONFLICT,
429: ApiErrorCode.RATE_LIMITED,
500: ApiErrorCode.SERVER_ERROR,
502: ApiErrorCode.SERVER_ERROR,
503: ApiErrorCode.SERVER_ERROR,
504: ApiErrorCode.SERVER_ERROR,
}
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
return new ApiError(message, code, status, details)
}
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError
}
}

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './api.config'
export * from './errors'
export * from './query.config'

View File

@ -0,0 +1,105 @@
// ===================
// © AngelaMos | 2026
// query.config.ts
// ===================
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { QUERY_CONFIG } from '@/config'
import { ApiError, ApiErrorCode } from './errors'
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
ApiErrorCode.AUTHENTICATION_ERROR,
ApiErrorCode.AUTHORIZATION_ERROR,
ApiErrorCode.NOT_FOUND,
ApiErrorCode.VALIDATION_ERROR,
] as const
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
if (error instanceof ApiError) {
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
return false
}
}
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
}
const calculateRetryDelay = (attemptIndex: number): number => {
const baseDelay = 1000
const maxDelay = 30000
return Math.min(baseDelay * 2 ** attemptIndex, maxDelay)
}
const handleQueryCacheError = (
error: Error,
query: { state: { data: unknown } }
): void => {
if (query.state.data !== undefined) {
const message =
error instanceof ApiError
? error.getUserMessage()
: 'Background update failed'
toast.error(message)
}
}
const handleMutationCacheError = (
error: Error,
_variables: unknown,
_context: unknown,
mutation: { options: { onError?: unknown } }
): void => {
if (mutation.options.onError === undefined) {
const message =
error instanceof ApiError ? error.getUserMessage() : 'Operation failed'
toast.error(message)
}
}
export const QUERY_STRATEGIES = {
standard: {
staleTime: QUERY_CONFIG.STALE_TIME.USER,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
},
frequent: {
staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT,
},
static: {
staleTime: QUERY_CONFIG.STALE_TIME.STATIC,
gcTime: QUERY_CONFIG.GC_TIME.LONG,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
auth: {
staleTime: QUERY_CONFIG.STALE_TIME.USER,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
retry: QUERY_CONFIG.RETRY.NONE,
},
} as const
export type QueryStrategy = keyof typeof QUERY_STRATEGIES
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: QUERY_CONFIG.STALE_TIME.USER,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
retry: shouldRetryQuery,
retryDelay: calculateRetryDelay,
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchOnReconnect: true,
},
mutations: {
retry: QUERY_CONFIG.RETRY.NONE,
},
},
queryCache: new QueryCache({
onError: handleQueryCacheError,
}),
mutationCache: new MutationCache({
onError: handleMutationCacheError,
}),
})

View File

@ -0,0 +1,30 @@
// ===================
// © AngelaMos | 2026
// routers.tsx
// ===================
import { createBrowserRouter, type RouteObject } from 'react-router-dom'
import { ROUTES } from '@/config'
import { Shell } from './shell'
const routes: RouteObject[] = [
{
element: <Shell />,
children: [
{
path: ROUTES.HOME,
lazy: () => import('@/pages/landing'),
},
{
path: ROUTES.ANALYSIS,
lazy: () => import('@/pages/analysis'),
},
{
path: '*',
lazy: () => import('@/pages/landing'),
},
],
},
]
export const router = createBrowserRouter(routes)

View File

@ -0,0 +1,331 @@
// ===================
// © AngelaMos | 2026
// shell.module.scss
// ===================
@use '@/styles' as *;
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$header-height: 56px;
.shell {
display: flex;
min-height: 100vh;
min-height: 100dvh;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: $sidebar-width;
background: $bg-surface-100;
border-right: 1px solid $border-default;
display: flex;
flex-direction: column;
z-index: $z-fixed;
@include transition-fast;
&.collapsed {
width: $sidebar-collapsed-width;
}
@include breakpoint-down('sm') {
transform: translateX(-100%);
&.open {
transform: translateX(0);
}
&.collapsed {
width: $sidebar-width;
}
}
}
.sidebarHeader {
height: $header-height;
padding: 0 $space-3;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $border-default;
.sidebar.collapsed & {
justify-content: center;
padding: 0;
}
}
.logo {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-default;
@include transition-fast;
.sidebar.collapsed & {
display: none;
}
}
.nav {
flex: 1;
padding: $space-3;
display: flex;
flex-direction: column;
gap: $space-1;
}
.navItem {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-2 $space-3;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-light;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
&.active {
background: $bg-selection;
color: $text-default;
}
.sidebar.collapsed & {
justify-content: center;
}
}
.navIcon {
width: 17px;
height: 17px;
flex-shrink: 0;
}
.navLabel {
@include transition-fast;
.sidebar.collapsed & {
display: none;
}
}
.adminItem {
margin-top: auto;
border-top: 1px solid $border-default;
padding-top: $space-3;
}
.collapseBtn {
width: 45px;
height: 45px;
border-radius: $radius-md;
color: $text-light;
@include flex-center;
@include transition-fast;
svg {
width: 23.5px;
height: 23.5px;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
}
@include breakpoint-down('sm') {
display: none;
}
}
.sidebarFooter {
padding: $space-3;
border-top: 1px solid $border-default;
}
.logoutBtn {
width: 100%;
display: flex;
align-items: center;
gap: $space-3;
padding: $space-3;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-default;
@include transition-fast;
@include hover {
background: $bg-surface-200;
}
.sidebar.collapsed & {
justify-content: center;
.logoutText {
display: none;
}
}
}
.logoutIcon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.logoutText {
font-weight: $font-weight-medium;
@include transition-fast;
}
.overlay {
position: fixed;
inset: 0;
background: rgb(0, 0, 0, 50%);
z-index: calc($z-fixed - 1);
display: none;
border: none;
padding: 0;
cursor: pointer;
@include breakpoint-down('sm') {
display: block;
}
}
.main {
flex: 1;
display: flex;
flex-direction: column;
margin-left: $sidebar-width;
min-width: 0;
@include transition-fast;
&.collapsed {
margin-left: $sidebar-collapsed-width;
}
@include breakpoint-down('sm') {
margin-left: 0;
&.collapsed {
margin-left: 0;
}
}
}
.header {
position: sticky;
top: 0;
height: $header-height;
background: $bg-surface-100;
border-bottom: 1px solid $border-default;
z-index: $z-sticky;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $space-4;
}
.headerLeft {
display: flex;
align-items: center;
gap: $space-3;
}
.menuBtn {
display: none;
width: 36px;
height: 36px;
border-radius: $radius-md;
color: $text-light;
align-items: center;
justify-content: center;
@include transition-fast;
svg {
width: 20px;
height: 20px;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
}
@media (width <= 479px) {
display: flex;
}
}
.pageTitle {
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $text-default;
margin-left: 7px;
}
.headerRight {
display: flex;
align-items: center;
gap: $space-3;
}
.avatar {
width: 32px;
height: 32px;
border-radius: $radius-full;
background: $bg-surface-300;
color: $text-light;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
@include flex-center;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(1.2);
}
}
.content {
flex: 1;
overflow-y: auto;
}
.loading {
@include flex-center;
height: 100%;
color: $text-muted;
}
.error {
@include flex-column-center;
height: 100%;
gap: $space-4;
padding: $space-6;
color: $error-default;
h2 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
}
pre {
font-family: $font-mono;
font-size: $font-size-sm;
padding: $space-4;
background: $bg-surface-200;
border-radius: $radius-lg;
overflow-x: auto;
max-width: 100%;
}
}

View File

@ -0,0 +1,36 @@
// ===================
// © AngelaMos | 2026
// shell.tsx
// ===================
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { Outlet } from 'react-router-dom'
import styles from './shell.module.scss'
function ShellErrorFallback({ error }: { error: Error }): React.ReactElement {
return (
<div className={styles.error}>
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</div>
)
}
function ShellLoading(): React.ReactElement {
return <div className={styles.loading}>Loading...</div>
}
export function Shell(): React.ReactElement {
return (
<div className={styles.shell}>
<main className={styles.content}>
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
<Suspense fallback={<ShellLoading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</main>
</div>
)
}

View File

@ -0,0 +1,67 @@
// ===================
// © AngelaMos | 2026
// toast.module.scss
// ===================
@use '@/styles' as *;
:global {
[data-sonner-toaster] {
--normal-bg: #{$bg-surface-100};
--normal-border: #{$border-default};
--normal-text: #{$text-default};
--success-bg: #{$bg-surface-100};
--success-border: #{$border-default};
--success-text: #{$text-default};
--error-bg: #{$bg-surface-100};
--error-border: #{$error-default};
--error-text: #{$text-default};
--warning-bg: #{$bg-surface-100};
--warning-border: #{$border-default};
--warning-text: #{$text-default};
--info-bg: #{$bg-surface-100};
--info-border: #{$border-default};
--info-text: #{$text-default};
font-family: $font-sans;
}
[data-sonner-toast] {
border-radius: $radius-md;
padding: $space-3 $space-4;
font-size: $font-size-sm;
border: 1px solid $border-default;
background: $bg-surface-100;
color: $text-default;
[data-title] {
font-weight: $font-weight-medium;
}
[data-description] {
color: $text-light;
font-size: $font-size-xs;
}
[data-close-button] {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: $text-muted;
@include transition-fast;
@include hover {
color: $text-default;
}
}
}
[data-sonner-toast][data-type='error'] {
border-color: $error-default;
}
}

View File

@ -0,0 +1,41 @@
// ===================
// © AngelaMos | 2026
// format.ts
// ===================
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB'] as const
const BYTES_PER_UNIT = 1024
const DEFAULT_HEX_PAD = 8
const DEFAULT_HASH_DISPLAY_LENGTH = 16
export function formatBytes(bytes: number): string {
let unitIndex = 0
let value = bytes
while (value >= BYTES_PER_UNIT && unitIndex < BYTE_UNITS.length - 1) {
value /= BYTES_PER_UNIT
unitIndex++
}
const decimals = unitIndex === 0 ? 0 : 2
return `${value.toFixed(decimals)} ${BYTE_UNITS[unitIndex]}`
}
export function formatHex(value: number, pad: number = DEFAULT_HEX_PAD): string {
return `0x${value.toString(16).toUpperCase().padStart(pad, '0')}`
}
export function truncateHash(
hash: string,
length: number = DEFAULT_HASH_DISPLAY_LENGTH
): string {
if (hash.length <= length) return hash
return `${hash.slice(0, length)}\u2026`
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}

View File

@ -0,0 +1,7 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './format'
export * from './shell.ui.store'

View File

@ -0,0 +1,63 @@
/**
* ©AngelaMos | 2026
* shell.ui.store.ts
*/
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
type Theme = 'light' | 'dark' | 'system'
interface UIState {
theme: Theme
sidebarOpen: boolean
sidebarCollapsed: boolean
setTheme: (theme: Theme) => void
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
toggleSidebarCollapsed: () => void
}
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
theme: 'dark',
sidebarOpen: false,
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }, false, 'ui/setTheme'),
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
'ui/toggleSidebar'
),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'),
toggleSidebarCollapsed: () =>
set(
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
false,
'ui/toggleSidebarCollapsed'
),
}),
{
name: 'ui-storage',
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
}),
}
),
{ name: 'UIStore' }
)
)
export const useTheme = (): Theme => useUIStore((s) => s.theme)
export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen)
export const useSidebarCollapsed = (): boolean =>
useUIStore((s) => s.sidebarCollapsed)

View File

@ -0,0 +1,15 @@
// ===========================
// ©AngelaMos | 2026
// main.tsx
// ===========================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.scss'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,195 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import type { AnalysisResponse } from '@/api'
import { useAnalysis } from '@/api'
import { RISK_LEVEL_COLORS, ROUTES } from '@/config'
import { copyToClipboard, formatBytes, formatHex, truncateHash } from '@/core/lib'
import styles from './analysis.module.scss'
import { TabDisassembly } from './tab-disassembly'
import { TabEntropy } from './tab-entropy'
import { TabHeaders } from './tab-headers'
import { TabImports } from './tab-imports'
import { TabOverview } from './tab-overview'
import { TabStrings } from './tab-strings'
type TabId =
| 'overview'
| 'headers'
| 'imports'
| 'strings'
| 'entropy'
| 'disassembly'
const TABS: readonly { id: TabId; label: string }[] = [
{ id: 'overview', label: 'OVERVIEW' },
{ id: 'headers', label: 'HEADERS' },
{ id: 'imports', label: 'IMPORTS' },
{ id: 'strings', label: 'STRINGS' },
{ id: 'entropy', label: 'ENTROPY' },
{ id: 'disassembly', label: 'DISASM' },
] as const
function renderTab(tab: TabId, data: AnalysisResponse): React.ReactElement {
switch (tab) {
case 'overview':
return <TabOverview data={data} />
case 'headers':
return <TabHeaders data={data} />
case 'imports':
return <TabImports data={data} />
case 'strings':
return <TabStrings data={data} />
case 'entropy':
return <TabEntropy data={data} />
case 'disassembly':
return <TabDisassembly data={data} />
}
}
function ScoreBar({
name,
score,
maxScore,
}: {
name: string
score: number
maxScore: number
}): React.ReactElement {
const pct = maxScore > 0 ? (score / maxScore) * 100 : 0
return (
<div className={styles.scoreBar}>
<div className={styles.scoreBarHeader}>
<span className={styles.scoreBarName}>{name}</span>
<span className={styles.scoreBarValue}>
{score}/{maxScore}
</span>
</div>
<div className={styles.scoreBarTrack}>
<div className={styles.scoreBarFill} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
export function Component(): React.ReactElement {
const { slug = '' } = useParams<{ slug: string }>()
const { data, isLoading, isError } = useAnalysis(slug)
const [activeTab, setActiveTab] = useState<TabId>('overview')
if (isLoading) {
return (
<div className={styles.state}>
<span className={styles.stateLabel}>ANALYZING SPECIMEN\u2026</span>
</div>
)
}
if (isError || !data) {
return (
<div className={styles.state}>
<span className={styles.stateCode}>404</span>
<span className={styles.stateLabel}>SPECIMEN NOT FOUND</span>
<Link to={ROUTES.HOME} className={styles.stateBack}>
NEW ANALYSIS
</Link>
</div>
)
}
const riskColor = data.risk_level
? (RISK_LEVEL_COLORS[data.risk_level] ?? '#888')
: '#888'
const handleCopyHash = async () => {
const ok = await copyToClipboard(data.sha256)
if (ok) toast.success('SHA-256 copied')
}
return (
<div className={styles.page}>
<header className={styles.header}>
<Link to={ROUTES.HOME} className={styles.backLink}>
AXUMORTEM
</Link>
<div className={styles.headerTop}>
<h1 className={styles.fileName}>{data.file_name}</h1>
<div className={styles.badges}>
<span className={styles.badge}>{data.format}</span>
<span className={styles.badge}>{data.architecture}</span>
<span className={styles.badge}>{formatBytes(data.file_size)}</span>
</div>
</div>
<div className={styles.headerMeta}>
<button
type="button"
className={styles.hashBtn}
onClick={handleCopyHash}
title={data.sha256}
>
<span className={styles.hashLabel}>SHA-256</span>
<span className={styles.hashValue}>{truncateHash(data.sha256)}</span>
</button>
{data.entry_point !== null && (
<span className={styles.metaItem}>
<span className={styles.metaLabel}>ENTRY</span>
<span className={styles.metaValue}>
{formatHex(data.entry_point)}
</span>
</span>
)}
</div>
</header>
{data.passes.threat && (
<section className={styles.scoreCard}>
<div className={styles.scoreMain}>
<span className={styles.scoreNumber} style={{ color: riskColor }}>
{data.threat_score ?? 0}
</span>
<div className={styles.scoreInfo}>
<span className={styles.riskLabel} style={{ color: riskColor }}>
{data.risk_level ?? 'UNKNOWN'}
</span>
<span className={styles.scoreSuffix}>/ 100 THREAT SCORE</span>
</div>
</div>
{data.passes.threat.categories.length > 0 && (
<div className={styles.scoreBars}>
{data.passes.threat.categories.map((cat) => (
<ScoreBar
key={cat.name}
name={cat.name}
score={cat.score}
maxScore={cat.max_score}
/>
))}
</div>
)}
</section>
)}
<nav className={styles.tabBar}>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${activeTab === tab.id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<div className={styles.tabContent}>{renderTab(activeTab, data)}</div>
</div>
)
}
Component.displayName = 'Analysis'

View File

@ -0,0 +1,304 @@
// ===================
// © AngelaMos | 2026
// tab-disassembly.tsx
// ===================
import { layout as dagreLayout, Graph } from '@dagrejs/dagre'
import { useMemo, useState } from 'react'
import type {
AnalysisResponse,
CfgEdge,
CfgEdgeType,
CfgNode,
FunctionInfo,
} from '@/api'
import { formatHex } from '@/core/lib'
import styles from './analysis.module.scss'
const CFG_NODE_WIDTH = 160
const CFG_NODE_HEIGHT = 40
const CFG_RANK_SEP = 60
const CFG_NODE_SEP = 30
const CFG_EDGE_COLORS: Record<CfgEdgeType, string> = {
Fallthrough: '#6b7280',
ConditionalTrue: '#22c55e',
ConditionalFalse: '#ef4444',
Unconditional: '#3b82f6',
Call: '#a855f7',
}
interface LayoutNode {
id: number
x: number
y: number
label: string
instructionCount: number
}
interface LayoutEdge {
from: { x: number; y: number }
to: { x: number; y: number }
color: string
}
interface CfgLayout {
nodes: LayoutNode[]
edges: LayoutEdge[]
width: number
height: number
}
function layoutCfg(nodes: CfgNode[], edges: CfgEdge[]): CfgLayout {
const g = new Graph()
g.setGraph({
rankdir: 'TB',
ranksep: CFG_RANK_SEP,
nodesep: CFG_NODE_SEP,
})
g.setDefaultEdgeLabel(() => ({}))
for (const node of nodes) {
g.setNode(String(node.id), {
width: CFG_NODE_WIDTH,
height: CFG_NODE_HEIGHT,
})
}
for (const edge of edges) {
g.setEdge(String(edge.from), String(edge.to))
}
dagreLayout(g)
const layoutNodes: LayoutNode[] = nodes.map((node) => {
const pos = g.node(String(node.id))
return {
id: node.id,
x: pos.x,
y: pos.y,
label: node.label,
instructionCount: node.instruction_count,
}
})
const layoutEdges: LayoutEdge[] = edges.map((edge) => {
const fromPos = g.node(String(edge.from))
const toPos = g.node(String(edge.to))
return {
from: { x: fromPos.x, y: fromPos.y + CFG_NODE_HEIGHT / 2 },
to: { x: toPos.x, y: toPos.y - CFG_NODE_HEIGHT / 2 },
color: CFG_EDGE_COLORS[edge.edge_type],
}
})
const graphInfo = g.graph()
return {
nodes: layoutNodes,
edges: layoutEdges,
width: (graphInfo.width ?? 400) + CFG_NODE_WIDTH,
height: (graphInfo.height ?? 300) + CFG_NODE_HEIGHT,
}
}
function CfgGraph({
nodes,
edges,
}: {
nodes: CfgNode[]
edges: CfgEdge[]
}): React.ReactElement {
const layout = useMemo(() => layoutCfg(nodes, edges), [nodes, edges])
const padX = CFG_NODE_WIDTH / 2
const padY = CFG_NODE_HEIGHT / 2
return (
<div className={styles.cfgContainer}>
<svg
className={styles.cfgSvg}
viewBox={`0 0 ${layout.width} ${layout.height}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Control flow graph"
>
<title>Control flow graph</title>
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#6b7280" />
</marker>
</defs>
{layout.edges.map((edge, i) => (
<line
key={`edge-${i.toString()}`}
x1={edge.from.x + padX}
y1={edge.from.y + padY}
x2={edge.to.x + padX}
y2={edge.to.y + padY}
stroke={edge.color}
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
))}
{layout.nodes.map((node) => (
<g key={node.id} transform={`translate(${node.x},${node.y})`}>
<rect
width={CFG_NODE_WIDTH}
height={CFG_NODE_HEIGHT}
fill="hsl(0, 0%, 12%)"
stroke="hsl(0, 0%, 22%)"
strokeWidth={1}
/>
<text
x={CFG_NODE_WIDTH / 2}
y={CFG_NODE_HEIGHT / 2 - 4}
textAnchor="middle"
fill="hsl(0, 0%, 98%)"
fontSize={10}
fontFamily="monospace"
>
{node.label}
</text>
<text
x={CFG_NODE_WIDTH / 2}
y={CFG_NODE_HEIGHT / 2 + 10}
textAnchor="middle"
fill="hsl(0, 0%, 54%)"
fontSize={9}
fontFamily="monospace"
>
{node.instructionCount} insn
</text>
</g>
))}
</svg>
</div>
)
}
function InstructionTable({ fn }: { fn: FunctionInfo }): React.ReactElement {
return (
<div className={styles.tableWrap}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>ADDRESS</th>
<th>BYTES</th>
<th>MNEMONIC</th>
<th>OPERANDS</th>
</tr>
</thead>
<tbody>
{fn.basic_blocks.map((block, blockIdx) => (
<>
{block.instructions.map((insn, i) => (
<tr
key={insn.address}
className={`${styles.tableRow} ${i === 0 && blockIdx > 0 ? styles.blockBoundary : ''}`}
>
<td className={styles.cellMono}>{formatHex(insn.address)}</td>
<td className={styles.cellMono}>
{insn.bytes
.map((b) => b.toString(16).padStart(2, '0'))
.join(' ')}
</td>
<td className={styles.cellMnemonic}>{insn.mnemonic}</td>
<td className={styles.cellMono}>{insn.operands}</td>
</tr>
))}
</>
))}
</tbody>
</table>
</div>
)
}
export function TabDisassembly({
data,
}: {
data: AnalysisResponse
}): React.ReactElement {
const disasm = data.passes.disassembly
const [selectedAddr, setSelectedAddr] = useState<number | null>(null)
if (!disasm) {
return (
<div className={styles.tabPanel}>
<span className={styles.noData}>
Disassembly is only available for x86 and x86_64 binaries
</span>
</div>
)
}
const selectedFn =
disasm.functions.find((f) => f.address === selectedAddr) ??
disasm.functions[0] ??
null
return (
<div className={styles.tabPanel}>
<div className={styles.disasmLayout}>
<aside className={styles.fnSidebar}>
<span className={styles.sectionLabel}>
FUNCTIONS ({disasm.total_functions})
</span>
<div className={styles.fnList}>
{disasm.functions.map((fn) => (
<button
key={fn.address}
type="button"
className={`${styles.fnItem} ${fn.address === selectedFn?.address ? styles.fnActive : ''} ${fn.is_entry_point ? styles.fnEntry : ''}`}
onClick={() => setSelectedAddr(fn.address)}
>
<span className={styles.fnAddr}>{formatHex(fn.address)}</span>
<span className={styles.fnName}>
{fn.name ?? `sub_${fn.address.toString(16)}`}
</span>
<span className={styles.fnMeta}>{fn.instruction_count} insn</span>
</button>
))}
</div>
</aside>
<div className={styles.disasmMain}>
{selectedFn ? (
<>
<div className={styles.fnHeader}>
<span className={styles.fnHeaderName}>
{selectedFn.name ?? `sub_${selectedFn.address.toString(16)}`}
</span>
<span className={styles.fnHeaderMeta}>
{formatHex(selectedFn.address)} / {selectedFn.size} bytes /{' '}
{selectedFn.instruction_count} instructions /{' '}
{selectedFn.basic_blocks.length} blocks
</span>
</div>
<InstructionTable fn={selectedFn} />
{selectedFn.cfg.nodes.length > 0 && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>CONTROL FLOW GRAPH</span>
<CfgGraph
nodes={selectedFn.cfg.nodes}
edges={selectedFn.cfg.edges}
/>
</section>
)}
</>
) : (
<span className={styles.noData}>No functions found</span>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,110 @@
// ===================
// © AngelaMos | 2026
// tab-entropy.tsx
// ===================
import type { AnalysisResponse, SectionEntropy } from '@/api'
import { ENTROPY_CLASSIFICATION_COLORS } from '@/config'
import styles from './analysis.module.scss'
const MAX_ENTROPY = 8
function EntropyBar({
section,
}: {
section: SectionEntropy
}): React.ReactElement {
const pct = (section.entropy / MAX_ENTROPY) * 100
const color = ENTROPY_CLASSIFICATION_COLORS[section.classification] ?? '#888'
return (
<div
className={`${styles.entropyRow} ${section.is_anomalous ? styles.anomalous : ''}`}
>
<div className={styles.entropyMeta}>
<span className={styles.entropySectionName}>{section.name}</span>
<span className={styles.entropyClassification} style={{ color }}>
{section.classification}
</span>
</div>
<div className={styles.entropyBarWrap}>
<div className={styles.entropyBarTrack}>
<div
className={styles.entropyBarFill}
style={{ width: `${pct}%`, background: color }}
/>
</div>
<span className={styles.entropyValue}>{section.entropy.toFixed(2)}</span>
</div>
<div className={styles.entropyDetails}>
<span className={styles.entropyDetail}>
SIZE {section.size.toLocaleString()}
</span>
<span className={styles.entropyDetail}>
V/R {section.virtual_to_raw_ratio.toFixed(2)}
</span>
{section.flags.length > 0 && (
<div className={styles.entropyFlags}>
{section.flags.map((flag) => (
<span key={flag} className={styles.entropyFlag}>
{flag}
</span>
))}
</div>
)}
</div>
</div>
)
}
export function TabEntropy({
data,
}: {
data: AnalysisResponse
}): React.ReactElement {
const ent = data.passes.entropy
if (!ent) {
return (
<div className={styles.tabPanel}>
<span className={styles.noData}>No entropy data available</span>
</div>
)
}
return (
<div className={styles.tabPanel}>
<div className={styles.entropyOverall}>
<span className={styles.entropyOverallLabel}>OVERALL ENTROPY</span>
<span className={styles.entropyOverallValue}>
{ent.overall_entropy.toFixed(4)}
</span>
<span className={styles.entropyOverallScale}>/ {MAX_ENTROPY}</span>
</div>
{ent.packing_detected && (
<div className={styles.packingAlert}>
<span className={styles.packingTitle}>PACKING DETECTED</span>
{ent.packer_name && (
<span className={styles.packingName}>{ent.packer_name}</span>
)}
{ent.packing_indicators.map((ind, i) => (
<div key={`ind-${i.toString()}`} className={styles.packingIndicator}>
<span className={styles.packingType}>{ind.indicator_type}</span>
<span className={styles.packingEvidence}>{ind.description}</span>
</div>
))}
</div>
)}
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>PER-SECTION ENTROPY</span>
<div className={styles.entropyBars}>
{ent.sections.map((sec) => (
<EntropyBar key={sec.name} section={sec} />
))}
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,315 @@
// ===================
// © AngelaMos | 2026
// tab-headers.tsx
// ===================
import { useState } from 'react'
import type { AnalysisResponse, SectionInfo, SegmentInfo } from '@/api'
import { formatHex } from '@/core/lib'
import styles from './analysis.module.scss'
function PermsBadge({
r,
w,
x,
}: {
r: boolean
w: boolean
x: boolean
}): React.ReactElement {
return (
<span className={styles.perms}>
<span className={r ? styles.permActive : styles.permInactive}>R</span>
<span className={w ? styles.permActive : styles.permInactive}>W</span>
<span className={x ? styles.permExec : styles.permInactive}>X</span>
</span>
)
}
function SectionRow({ section }: { section: SectionInfo }): React.ReactElement {
return (
<tr className={styles.tableRow}>
<td className={styles.cellMono}>{section.name}</td>
<td className={styles.cellMono}>{formatHex(section.virtual_address)}</td>
<td className={styles.cellRight}>
{section.virtual_size.toLocaleString()}
</td>
<td className={styles.cellRight}>{section.raw_size.toLocaleString()}</td>
<td>
<PermsBadge
r={section.permissions.read}
w={section.permissions.write}
x={section.permissions.execute}
/>
</td>
</tr>
)
}
function SegmentRow({ segment }: { segment: SegmentInfo }): React.ReactElement {
return (
<tr className={styles.tableRow}>
<td className={styles.cellMono}>{segment.name ?? '\u2014'}</td>
<td className={styles.cellMono}>{formatHex(segment.virtual_address)}</td>
<td className={styles.cellRight}>
{segment.virtual_size.toLocaleString()}
</td>
<td className={styles.cellRight}>{segment.file_size.toLocaleString()}</td>
<td>
<PermsBadge
r={segment.permissions.read}
w={segment.permissions.write}
x={segment.permissions.execute}
/>
</td>
</tr>
)
}
export function TabHeaders({
data,
}: {
data: AnalysisResponse
}): React.ReactElement {
const [showSegments, setShowSegments] = useState(false)
const fmt = data.passes.format
if (!fmt) {
return (
<div className={styles.tabPanel}>
<span className={styles.noData}>No format data available</span>
</div>
)
}
return (
<div className={styles.tabPanel}>
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>FORMAT INFO</span>
<div className={styles.metaGrid}>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>FORMAT</span>
<span className={styles.metaFieldValue}>{fmt.format}</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>ARCH</span>
<span className={styles.metaFieldValue}>
{typeof fmt.architecture === 'string'
? fmt.architecture
: fmt.architecture.Other}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>BITS</span>
<span className={styles.metaFieldValue}>{fmt.bits}</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>ENDIAN</span>
<span className={styles.metaFieldValue}>{fmt.endianness}</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>ENTRY</span>
<span className={styles.metaFieldValue}>
{formatHex(fmt.entry_point)}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>STRIPPED</span>
<span className={styles.metaFieldValue}>
{fmt.is_stripped ? 'YES' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>PIE</span>
<span className={styles.metaFieldValue}>
{fmt.is_pie ? 'YES' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>DEBUG</span>
<span className={styles.metaFieldValue}>
{fmt.has_debug_info ? 'YES' : 'NO'}
</span>
</div>
</div>
</section>
{fmt.pe_info && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>PE INFO</span>
<div className={styles.metaGrid}>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>IMAGE BASE</span>
<span className={styles.metaFieldValue}>
{formatHex(fmt.pe_info.image_base)}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>SUBSYSTEM</span>
<span className={styles.metaFieldValue}>
{fmt.pe_info.subsystem}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>LINKER</span>
<span className={styles.metaFieldValue}>
{fmt.pe_info.linker_version}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>ASLR</span>
<span className={styles.metaFieldValue}>
{fmt.pe_info.dll_characteristics.aslr ? 'YES' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>DEP</span>
<span className={styles.metaFieldValue}>
{fmt.pe_info.dll_characteristics.dep ? 'YES' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>CFG</span>
<span className={styles.metaFieldValue}>
{fmt.pe_info.dll_characteristics.cfg ? 'YES' : 'NO'}
</span>
</div>
</div>
</section>
)}
{fmt.elf_info && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>ELF INFO</span>
<div className={styles.metaGrid}>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>OS ABI</span>
<span className={styles.metaFieldValue}>{fmt.elf_info.os_abi}</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>TYPE</span>
<span className={styles.metaFieldValue}>
{fmt.elf_info.elf_type}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>RELRO</span>
<span className={styles.metaFieldValue}>
{fmt.elf_info.gnu_relro ? 'FULL' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>BIND NOW</span>
<span className={styles.metaFieldValue}>
{fmt.elf_info.bind_now ? 'YES' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>NX STACK</span>
<span className={styles.metaFieldValue}>
{fmt.elf_info.stack_executable ? 'EXEC' : 'NO-EXEC'}
</span>
</div>
</div>
{fmt.elf_info.needed_libraries.length > 0 && (
<div className={styles.libList}>
<span className={styles.metaFieldLabel}>LIBRARIES</span>
{fmt.elf_info.needed_libraries.map((lib) => (
<span key={lib} className={styles.libItem}>
{lib}
</span>
))}
</div>
)}
</section>
)}
{fmt.macho_info && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>MACH-O INFO</span>
<div className={styles.metaGrid}>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>FILE TYPE</span>
<span className={styles.metaFieldValue}>
{fmt.macho_info.file_type}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>UNIVERSAL</span>
<span className={styles.metaFieldValue}>
{fmt.macho_info.is_universal ? 'YES' : 'NO'}
</span>
</div>
<div className={styles.metaField}>
<span className={styles.metaFieldLabel}>CODE SIGN</span>
<span className={styles.metaFieldValue}>
{fmt.macho_info.has_code_signature ? 'YES' : 'NO'}
</span>
</div>
</div>
</section>
)}
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>
SECTIONS ({fmt.sections.length})
</span>
<div className={styles.tableWrap}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>NAME</th>
<th>VADDR</th>
<th className={styles.cellRight}>VSIZE</th>
<th className={styles.cellRight}>RAW SIZE</th>
<th>PERMS</th>
</tr>
</thead>
<tbody>
{fmt.sections.map((sec) => (
<SectionRow key={sec.name} section={sec} />
))}
</tbody>
</table>
</div>
</section>
{fmt.segments.length > 0 && (
<section className={styles.overviewSection}>
<button
type="button"
className={styles.collapseBtn}
onClick={() => setShowSegments((prev) => !prev)}
>
<span className={styles.sectionLabel}>
SEGMENTS ({fmt.segments.length})
</span>
<span className={styles.collapseIcon}>
{showSegments ? '\u25B2' : '\u25BC'}
</span>
</button>
{showSegments && (
<div className={styles.tableWrap}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>NAME</th>
<th>VADDR</th>
<th className={styles.cellRight}>VSIZE</th>
<th className={styles.cellRight}>FSIZE</th>
<th>PERMS</th>
</tr>
</thead>
<tbody>
{fmt.segments.map((seg, i) => (
<SegmentRow key={`seg-${i.toString()}`} segment={seg} />
))}
</tbody>
</table>
</div>
)}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,205 @@
// ===================
// © AngelaMos | 2026
// tab-imports.tsx
// ===================
import { useState } from 'react'
import type { AnalysisResponse, ImportEntry } from '@/api'
import { formatHex } from '@/core/lib'
import styles from './analysis.module.scss'
function ImportRow({ entry }: { entry: ImportEntry }): React.ReactElement {
return (
<tr
className={`${styles.tableRow} ${entry.is_suspicious ? styles.suspicious : ''}`}
>
<td className={styles.cellMono}>{entry.function}</td>
<td className={styles.cellMono}>
{entry.address !== null ? formatHex(entry.address) : '\u2014'}
</td>
<td className={styles.cellMono}>
{entry.ordinal !== null ? `#${entry.ordinal}` : '\u2014'}
</td>
<td>
{entry.threat_tags.length > 0 && (
<div className={styles.tagRow}>
{entry.threat_tags.map((tag) => (
<span key={tag} className={styles.threatTag}>
{tag}
</span>
))}
</div>
)}
</td>
</tr>
)
}
function LibraryGroup({
library,
imports,
isOpen,
onToggle,
}: {
library: string
imports: ImportEntry[]
isOpen: boolean
onToggle: () => void
}): React.ReactElement {
const suspiciousCount = imports.filter((i) => i.is_suspicious).length
return (
<div className={styles.libraryGroup}>
<button type="button" className={styles.libraryHeader} onClick={onToggle}>
<span className={styles.libraryName}>{library}</span>
<span className={styles.libraryMeta}>
{imports.length} imports
{suspiciousCount > 0 && (
<span className={styles.suspiciousCount}>
{suspiciousCount} suspicious
</span>
)}
</span>
<span className={styles.collapseIcon}>
{isOpen ? '\u25B2' : '\u25BC'}
</span>
</button>
{isOpen && (
<div className={styles.tableWrap}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>FUNCTION</th>
<th>ADDRESS</th>
<th>ORDINAL</th>
<th>TAGS</th>
</tr>
</thead>
<tbody>
{imports.map((entry, i) => (
<ImportRow
key={`${entry.function}-${i.toString()}`}
entry={entry}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
export function TabImports({
data,
}: {
data: AnalysisResponse
}): React.ReactElement {
const imp = data.passes.imports
const [openLibs, setOpenLibs] = useState<Set<string>>(new Set())
if (!imp) {
return (
<div className={styles.tabPanel}>
<span className={styles.noData}>No import data available</span>
</div>
)
}
const toggleLib = (lib: string) => {
setOpenLibs((prev) => {
const next = new Set(prev)
if (next.has(lib)) next.delete(lib)
else next.add(lib)
return next
})
}
const importsByLib = new Map<string, ImportEntry[]>()
for (const entry of imp.imports) {
const list = importsByLib.get(entry.library) ?? []
list.push(entry)
importsByLib.set(entry.library, list)
}
return (
<div className={styles.tabPanel}>
{imp.suspicious_combinations.length > 0 && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>SUSPICIOUS COMBINATIONS</span>
<div className={styles.alertCards}>
{imp.suspicious_combinations.map((combo) => (
<div key={combo.name} className={styles.alertCard}>
<div className={styles.alertHeader}>
<span className={styles.alertName}>{combo.name}</span>
<span className={styles.mitrePill}>{combo.mitre_id}</span>
<span
className={`${styles.severityBadge} ${styles[`severity${combo.severity}`]}`}
>
{combo.severity}
</span>
</div>
<span className={styles.alertDesc}>{combo.description}</span>
<div className={styles.alertApis}>
{combo.apis.map((api) => (
<span key={api} className={styles.apiTag}>
{api}
</span>
))}
</div>
</div>
))}
</div>
</section>
)}
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>
IMPORTS ({imp.statistics.total_imports})
</span>
{Array.from(importsByLib.entries()).map(([lib, imports]) => (
<LibraryGroup
key={lib}
library={lib}
imports={imports}
isOpen={openLibs.has(lib)}
onToggle={() => toggleLib(lib)}
/>
))}
</section>
{imp.exports.length > 0 && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>
EXPORTS ({imp.exports.length})
</span>
<div className={styles.tableWrap}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>NAME</th>
<th>ADDRESS</th>
<th>ORDINAL</th>
<th>FORWARD</th>
</tr>
</thead>
<tbody>
{imp.exports.map((exp, i) => (
<tr key={`exp-${i.toString()}`} className={styles.tableRow}>
<td className={styles.cellMono}>{exp.name ?? '\u2014'}</td>
<td className={styles.cellMono}>{formatHex(exp.address)}</td>
<td className={styles.cellMono}>
{exp.ordinal !== null ? `#${exp.ordinal}` : '\u2014'}
</td>
<td className={styles.cellMono}>
{exp.forward_target ?? '\u2014'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,144 @@
// ===================
// © AngelaMos | 2026
// tab-overview.tsx
// ===================
import type { AnalysisResponse } from '@/api'
import styles from './analysis.module.scss'
const MITRE_BASE_URL = 'https://attack.mitre.org/techniques/'
function formatMitreUrl(id: string): string {
return `${MITRE_BASE_URL}${id.replace('.', '/')}`
}
export function TabOverview({
data,
}: {
data: AnalysisResponse
}): React.ReactElement {
const { passes } = data
return (
<div className={styles.tabPanel}>
<div className={styles.summaryGrid}>
{passes.format && (
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>FORMAT</span>
<span className={styles.summaryValue}>
{passes.format.format} / {passes.format.bits}-bit
</span>
<span className={styles.summaryDetail}>
{passes.format.sections.length} sections,{' '}
{passes.format.segments.length} segments
</span>
</div>
)}
{passes.imports && (
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>IMPORTS</span>
<span className={styles.summaryValue}>
{passes.imports.statistics.total_imports} across{' '}
{passes.imports.statistics.library_count} libraries
</span>
<span className={styles.summaryDetail}>
{passes.imports.statistics.suspicious_count} suspicious
</span>
</div>
)}
{passes.strings && (
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>STRINGS</span>
<span className={styles.summaryValue}>
{passes.strings.statistics.total} extracted
</span>
<span className={styles.summaryDetail}>
{passes.strings.statistics.suspicious_count} suspicious
</span>
</div>
)}
{passes.entropy && (
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>ENTROPY</span>
<span className={styles.summaryValue}>
{passes.entropy.overall_entropy.toFixed(2)} overall
</span>
<span className={styles.summaryDetail}>
{passes.entropy.packing_detected
? `Packing detected${passes.entropy.packer_name ? `: ${passes.entropy.packer_name}` : ''}`
: 'No packing detected'}
</span>
</div>
)}
{passes.disassembly && (
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>DISASSEMBLY</span>
<span className={styles.summaryValue}>
{passes.disassembly.total_functions} functions
</span>
<span className={styles.summaryDetail}>
{passes.disassembly.total_instructions} instructions
</span>
</div>
)}
{passes.threat && (
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>YARA</span>
<span className={styles.summaryValue}>
{passes.threat.yara_matches.length} rule matches
</span>
<span className={styles.summaryDetail}>{passes.threat.summary}</span>
</div>
)}
</div>
{passes.format && passes.format.anomalies.length > 0 && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>ANOMALIES</span>
<div className={styles.anomalyList}>
{passes.format.anomalies.map((anomaly, i) => (
<div key={`anomaly-${i.toString()}`} className={styles.anomalyItem}>
{typeof anomaly === 'string' ? (
<span>{anomaly}</span>
) : (
Object.entries(anomaly).map(([key, val]) => (
<span key={key}>
{key}: {String(val)}
</span>
))
)}
</div>
))}
</div>
</section>
)}
{passes.threat && passes.threat.mitre_techniques.length > 0 && (
<section className={styles.overviewSection}>
<span className={styles.sectionLabel}>MITRE ATT&CK</span>
<div className={styles.mitrePills}>
{passes.threat.mitre_techniques.map((technique) => (
<a
key={technique.technique_id}
href={formatMitreUrl(technique.technique_id)}
target="_blank"
rel="noopener noreferrer"
className={styles.mitrePill}
>
<span className={styles.mitreId}>{technique.technique_id}</span>
<span className={styles.mitreName}>
{technique.technique_name}
</span>
</a>
))}
</div>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,233 @@
// ===================
// © AngelaMos | 2026
// tab-strings.tsx
// ===================
import { useMemo, useState } from 'react'
import type {
AnalysisResponse,
ExtractedString,
StringCategory,
StringEncoding,
} from '@/api'
import { formatHex } from '@/core/lib'
import styles from './analysis.module.scss'
const ENCODING_OPTIONS: readonly ('All' | StringEncoding)[] = [
'All',
'Ascii',
'Utf8',
'Utf16Le',
] as const
const CATEGORY_OPTIONS: readonly ('All' | StringCategory)[] = [
'All',
'Url',
'IpAddress',
'FilePath',
'RegistryKey',
'ShellCommand',
'CryptoWallet',
'Email',
'SuspiciousApi',
'PackerSignature',
'DebugArtifact',
'AntiAnalysis',
'PersistencePath',
'EncodedData',
'Generic',
] as const
const PAGE_SIZE = 50
function StringRow({
str,
expanded,
onToggle,
}: {
str: ExtractedString
expanded: boolean
onToggle: () => void
}): React.ReactElement {
const MAX_DISPLAY = 80
const needsTruncate = str.value.length > MAX_DISPLAY
const display =
expanded || !needsTruncate
? str.value
: `${str.value.slice(0, MAX_DISPLAY)}\u2026`
return (
<tr
className={`${styles.tableRow} ${str.is_suspicious ? styles.suspicious : ''}`}
>
<td className={styles.cellMono}>{formatHex(str.offset)}</td>
<td>
<button
type="button"
className={styles.stringValue}
onClick={needsTruncate ? onToggle : undefined}
>
{display}
</button>
</td>
<td className={styles.cellMono}>{str.encoding}</td>
<td>
<span className={styles.categoryBadge}>{str.category}</span>
</td>
<td className={styles.cellMono}>{str.section ?? '\u2014'}</td>
</tr>
)
}
export function TabStrings({
data,
}: {
data: AnalysisResponse
}): React.ReactElement {
const str = data.passes.strings
const [search, setSearch] = useState('')
const [encoding, setEncoding] = useState<'All' | StringEncoding>('All')
const [category, setCategory] = useState<'All' | StringCategory>('All')
const [suspiciousOnly, setSuspiciousOnly] = useState(false)
const [page, setPage] = useState(0)
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
const filtered = useMemo(() => {
if (!str) return []
return str.strings.filter((s) => {
if (search && !s.value.toLowerCase().includes(search.toLowerCase()))
return false
if (encoding !== 'All' && s.encoding !== encoding) return false
if (category !== 'All' && s.category !== category) return false
if (suspiciousOnly && !s.is_suspicious) return false
return true
})
}, [str, search, encoding, category, suspiciousOnly])
if (!str) {
return (
<div className={styles.tabPanel}>
<span className={styles.noData}>No string data available</span>
</div>
)
}
const totalPages = Math.ceil(filtered.length / PAGE_SIZE)
const pageStrings = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const toggleRow = (offset: number) => {
setExpandedRows((prev) => {
const next = new Set(prev)
if (next.has(offset)) next.delete(offset)
else next.add(offset)
return next
})
}
return (
<div className={styles.tabPanel}>
<div className={styles.filterBar}>
<input
type="text"
className={styles.searchInput}
placeholder="SEARCH STRINGS..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
/>
<select
className={styles.filterSelect}
value={encoding}
onChange={(e) => {
setEncoding(e.target.value as 'All' | StringEncoding)
setPage(0)
}}
>
{ENCODING_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => {
setCategory(e.target.value as 'All' | StringCategory)
setPage(0)
}}
>
{CATEGORY_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<button
type="button"
className={`${styles.filterToggle} ${suspiciousOnly ? styles.filterActive : ''}`}
onClick={() => {
setSuspiciousOnly((p) => !p)
setPage(0)
}}
>
SUSPICIOUS
</button>
</div>
<span className={styles.filterCount}>
{filtered.length} / {str.statistics.total} strings
</span>
<div className={styles.tableWrap}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>OFFSET</th>
<th>VALUE</th>
<th>ENCODING</th>
<th>CATEGORY</th>
<th>SECTION</th>
</tr>
</thead>
<tbody>
{pageStrings.map((s) => (
<StringRow
key={s.offset}
str={s}
expanded={expandedRows.has(s.offset)}
onToggle={() => toggleRow(s.offset)}
/>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
PREV
</button>
<span className={styles.pageInfo}>
{page + 1} / {totalPages}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
NEXT
</button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,205 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
import { useCallback, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useUpload } from '@/api'
import { formatBytes } from '@/core/lib'
import styles from './landing.module.scss'
const HEX_OFFSETS = Array.from(
{ length: 16 },
(_, i) => `0x${(i * 16).toString(16).toUpperCase().padStart(4, '0')}`
)
const ANALYSIS_PASSES = [
{ id: '01', name: 'FORMAT' },
{ id: '02', name: 'IMPORTS' },
{ id: '03', name: 'STRINGS' },
{ id: '04', name: 'ENTROPY' },
{ id: '05', name: 'DISASM' },
{ id: '06', name: 'THREAT' },
] as const
export function Component(): React.ReactElement {
const [file, setFile] = useState<File | null>(null)
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const upload = useUpload()
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
const related = e.relatedTarget as Node | null
if (related && e.currentTarget.contains(related)) return
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) setFile(droppedFile)
}, [])
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0]
if (selected) setFile(selected)
},
[]
)
const handleSubmit = useCallback(() => {
if (!file) return
upload.mutate(file, {
onSuccess: (data) => navigate(`/analysis/${data.slug}`),
onError: () => toast.error('Failed to analyze binary'),
})
}, [file, upload, navigate])
const handleClear = useCallback(() => {
setFile(null)
upload.reset()
if (inputRef.current) inputRef.current.value = ''
}, [upload])
return (
<div className={styles.page}>
<div className={styles.hexMargin} aria-hidden="true">
{HEX_OFFSETS.map((offset) => (
<span key={offset}>{offset}</span>
))}
</div>
<div className={styles.cornerTL} aria-hidden="true" />
<div className={styles.cornerTR} aria-hidden="true" />
<div className={styles.cornerBL} aria-hidden="true" />
<div className={styles.cornerBR} aria-hidden="true" />
<header className={styles.metaStrip}>
<span>AXM-001</span>
<span className={styles.metaCenter}>STATIC ANALYSIS SUITE</span>
<span>v0.1.0</span>
</header>
<div className={styles.rule} />
<section className={styles.hero}>
<h1 className={styles.title}>AXUMORTEM</h1>
<p className={styles.subtitle}>BINARY DISSECTION ENGINE</p>
<p className={styles.formats}>
<span>ELF</span>
<span className={styles.formatDivider}>/</span>
<span>PE</span>
<span className={styles.formatDivider}>/</span>
<span>MACH-O</span>
</p>
</section>
<section className={styles.intake}>
<div className={styles.intakeHeader}>
<span className={styles.intakeLabel}>SPECIMEN INTAKE</span>
{file && (
<button
type="button"
className={styles.clearBtn}
onClick={handleClear}
>
CLEAR
</button>
)}
</div>
<section
className={`${styles.dropZone} ${isDragging ? styles.dragActive : ''} ${file ? styles.hasFile : ''}`}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
aria-label="File drop zone"
>
<input ref={inputRef} type="file" hidden onChange={handleFileSelect} />
{file ? (
<div className={styles.fileInfo}>
<div className={styles.fileField}>
<span className={styles.fieldLabel}>FILE</span>
<span className={styles.fieldValue}>{file.name}</span>
</div>
<div className={styles.fileField}>
<span className={styles.fieldLabel}>SIZE</span>
<span className={styles.fieldValue}>
{formatBytes(file.size)}
</span>
</div>
<div className={styles.fileField}>
<span className={styles.fieldLabel}>TYPE</span>
<span className={styles.fieldValue}>
{file.type || 'application/octet-stream'}
</span>
</div>
</div>
) : (
<button
type="button"
className={styles.dropPrompt}
onClick={() => inputRef.current?.click()}
>
<span className={styles.dropStatus}>AWAITING SPECIMEN</span>
<span className={styles.dropInstruction}>
DRAG + DROP BINARY / CLICK TO BROWSE
</span>
</button>
)}
</section>
{file && (
<button
type="button"
className={styles.submitBtn}
onClick={handleSubmit}
disabled={upload.isPending}
>
{upload.isPending ? 'ANALYZING\u2026' : 'SUBMIT SPECIMEN'}
</button>
)}
</section>
<div className={styles.dashedRule} />
<section className={styles.pipeline}>
<span className={styles.pipelineLabel}>ANALYSIS PIPELINE</span>
<div className={styles.passes}>
{ANALYSIS_PASSES.map((pass) => (
<div key={pass.id} className={styles.pass}>
<span className={styles.passNumber}>{pass.id}</span>
<span className={styles.passName}>{pass.name}</span>
</div>
))}
</div>
</section>
<div className={styles.rule} />
<footer className={styles.footer}>
<span>&copy; ANGELAMOS 2026</span>
<span>AXUMORTEM</span>
</footer>
</div>
)
}
Component.displayName = 'Landing'

View File

@ -0,0 +1,443 @@
// ===================
// © AngelaMos | 2026
// landing.module.scss
// ===================
@use '@/styles' as *;
$corner-size: 12px;
$corner-weight: 1px;
$corner-offset: $space-4;
$hex-col-width: 72px;
$content-max: 860px;
.page {
position: relative;
min-height: 100vh;
min-height: 100dvh;
background: $bg-default;
display: flex;
flex-direction: column;
align-items: center;
padding: $space-4 $space-6;
overflow: hidden;
@include breakpoint-up('md') {
padding: $space-4 $space-8;
padding-left: calc($hex-col-width + $space-10);
}
}
.hexMargin {
display: none;
position: fixed;
left: $space-5;
top: 0;
bottom: 0;
width: $hex-col-width;
flex-direction: column;
justify-content: center;
gap: $space-6;
font-family: $font-mono;
font-size: $font-size-3xs;
color: $text-muted;
letter-spacing: $tracking-wide;
opacity: 0.4;
pointer-events: none;
user-select: none;
@include breakpoint-up('md') {
display: flex;
}
}
.cornerTL,
.cornerTR,
.cornerBL,
.cornerBR {
position: fixed;
width: $corner-size;
height: $corner-size;
pointer-events: none;
z-index: $z-base;
&::before,
&::after {
content: '';
position: absolute;
background: $border-strong;
}
}
.cornerTL {
top: $corner-offset;
left: $corner-offset;
&::before {
top: 0;
left: 0;
width: $corner-size;
height: $corner-weight;
}
&::after {
top: 0;
left: 0;
width: $corner-weight;
height: $corner-size;
}
}
.cornerTR {
top: $corner-offset;
right: $corner-offset;
&::before {
top: 0;
right: 0;
width: $corner-size;
height: $corner-weight;
}
&::after {
top: 0;
right: 0;
width: $corner-weight;
height: $corner-size;
}
}
.cornerBL {
bottom: $corner-offset;
left: $corner-offset;
&::before {
bottom: 0;
left: 0;
width: $corner-size;
height: $corner-weight;
}
&::after {
bottom: 0;
left: 0;
width: $corner-weight;
height: $corner-size;
}
}
.cornerBR {
bottom: $corner-offset;
right: $corner-offset;
&::before {
bottom: 0;
right: 0;
width: $corner-size;
height: $corner-weight;
}
&::after {
bottom: 0;
right: 0;
width: $corner-weight;
height: $corner-size;
}
}
.metaStrip {
width: 100%;
max-width: $content-max;
display: flex;
justify-content: space-between;
align-items: center;
font-family: $font-mono;
font-size: $font-size-3xs;
text-transform: uppercase;
letter-spacing: $tracking-widest;
color: $text-lighter;
padding: $space-3 0;
}
.metaCenter {
display: none;
@include breakpoint-up('sm') {
display: inline;
}
}
.rule {
width: 100%;
max-width: $content-max;
height: 1px;
background: $border-default;
}
.dashedRule {
width: 100%;
max-width: $content-max;
height: 1px;
background: repeating-linear-gradient(
90deg,
$border-default 0,
$border-default 6px,
transparent 6px,
transparent 12px
);
}
.hero {
width: 100%;
max-width: $content-max;
padding: $space-16 0 $space-12;
display: flex;
flex-direction: column;
gap: $space-3;
@include breakpoint-up('md') {
padding: $space-20 0 $space-16;
}
}
.title {
font-size: $font-size-6xl;
font-weight: $font-weight-black;
letter-spacing: 0.06em;
line-height: $line-height-none;
color: $text-default;
@include breakpoint-up('sm') {
font-size: $font-size-7xl;
}
@include breakpoint-up('md') {
font-size: $font-size-8xl;
}
}
.subtitle {
font-family: $font-mono;
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: $tracking-widest;
color: $text-lighter;
margin-top: $space-2;
}
.formats {
font-family: $font-mono;
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: $tracking-wider;
color: $text-muted;
display: flex;
align-items: center;
gap: $space-2;
margin-top: $space-1;
}
.formatDivider {
color: $border-strong;
}
.intake {
width: 100%;
max-width: $content-max;
padding: $space-8 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.intakeHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.intakeLabel {
font-family: $font-mono;
font-size: $font-size-2xs;
text-transform: uppercase;
letter-spacing: $tracking-widest;
color: $accent;
}
.clearBtn {
font-family: $font-mono;
font-size: $font-size-3xs;
text-transform: uppercase;
letter-spacing: $tracking-wider;
color: $text-muted;
@include transition-fast;
@include hover {
color: $text-light;
}
}
.dropZone {
border: 1px solid $border-default;
padding: $space-10 $space-6;
cursor: pointer;
transition-property: border-color, background-color;
transition-duration: $duration-fast;
transition-timing-function: $ease-out;
@include hover {
border-color: $border-strong;
}
&.dragActive {
border-color: $accent;
background: $accent-dim;
}
&.hasFile {
cursor: default;
background: $bg-surface-75;
padding: $space-6;
}
}
.dropPrompt {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-3;
text-align: center;
}
.dropStatus {
font-family: $font-mono;
font-size: $font-size-sm;
text-transform: uppercase;
letter-spacing: $tracking-wider;
color: $text-light;
}
.dropInstruction {
font-family: $font-mono;
font-size: $font-size-3xs;
text-transform: uppercase;
letter-spacing: $tracking-wide;
color: $text-muted;
}
.fileInfo {
display: flex;
flex-direction: column;
gap: $space-2;
}
.fileField {
display: flex;
align-items: baseline;
gap: $space-4;
}
.fieldLabel {
font-family: $font-mono;
font-size: $font-size-3xs;
text-transform: uppercase;
letter-spacing: $tracking-widest;
color: $text-muted;
min-width: 40px;
flex-shrink: 0;
}
.fieldValue {
font-family: $font-mono;
font-size: $font-size-sm;
color: $text-default;
word-break: break-all;
}
.submitBtn {
width: 100%;
padding: $space-3-5 $space-6;
background: $accent;
color: $bg-default;
font-family: $font-mono;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
text-transform: uppercase;
letter-spacing: $tracking-widest;
border: none;
transition-property: filter, opacity;
transition-duration: $duration-fast;
transition-timing-function: $ease-out;
@include hover {
filter: brightness(1.1);
}
&:disabled {
opacity: 0.7;
cursor: wait;
}
}
.pipeline {
width: 100%;
max-width: $content-max;
padding: $space-8 0;
display: flex;
flex-direction: column;
gap: $space-5;
}
.pipelineLabel {
font-family: $font-mono;
font-size: $font-size-2xs;
text-transform: uppercase;
letter-spacing: $tracking-widest;
color: $text-lighter;
}
.passes {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $space-3 $space-6;
@include breakpoint-down('sm') {
grid-template-columns: repeat(2, 1fr);
}
}
.pass {
display: flex;
align-items: center;
gap: $space-3;
}
.passNumber {
font-family: $font-mono;
font-size: $font-size-2xs;
color: $text-muted;
letter-spacing: $tracking-wide;
}
.passName {
font-family: $font-mono;
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: $tracking-wider;
color: $text-light;
}
.footer {
width: 100%;
max-width: $content-max;
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-6 0 $space-4;
font-family: $font-mono;
font-size: $font-size-3xs;
text-transform: uppercase;
letter-spacing: $tracking-widest;
color: $text-muted;
margin-top: auto;
}

View File

@ -0,0 +1,29 @@
// ===================
// © AngelaMos | 2026
// styles.scss
// ===================
@forward 'styles/tokens';
@forward 'styles/fonts';
@forward 'styles/mixins';
@use 'styles/reset';
@use 'styles/tokens' as *;
@use 'styles/fonts' as *;
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: $bg-default;
}
.app {
flex: 1;
display: flex;
flex-direction: column;
background: $bg-default;
color: $text-default;
font-family: $font-sans;
}

View File

@ -0,0 +1,12 @@
// ===================
// © AngelaMos | 2026
// _fonts.scss
// ===================
@use 'tokens' as *;
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto,
'Helvetica Neue', Arial, sans-serif;
$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2026
// _index.scss
// ===================
@forward 'tokens';
@forward 'fonts';
@forward 'mixins';

View File

@ -0,0 +1,120 @@
// ===================
// © AngelaMos | 2026
// _mixins.scss
// ===================
@use 'sass:map';
@use 'sass:list';
@use 'tokens' as *;
$breakpoints: (
'xs': $breakpoint-xs,
'sm': $breakpoint-sm,
'md': $breakpoint-md,
'lg': $breakpoint-lg,
'xl': $breakpoint-xl,
'2xl': $breakpoint-2xl,
);
@mixin breakpoint-up($size) {
@media (min-width: map.get($breakpoints, $size)) {
@content;
}
}
@mixin breakpoint-down($size) {
@media (width < map.get($breakpoints, $size)) {
@content;
}
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
@mixin flex-column-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@mixin truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin line-clamp($lines: 2) {
display: -webkit-box;
-webkit-line-clamp: #{$lines};
-webkit-box-orient: vertical;
overflow: hidden;
}
@mixin transition-fast {
transition-property: background-color, border-color, color, opacity;
transition-duration: $duration-fast;
transition-timing-function: $ease-out;
}
@mixin transition-normal {
transition-property: background-color, border-color, color, opacity;
transition-duration: $duration-normal;
transition-timing-function: $ease-out;
}
@mixin absolute-fill {
position: absolute;
inset: 0;
}
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@mixin hover {
@media (hover: hover) and (pointer: fine) {
&:hover {
@content;
}
}
}
@mixin hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}

View File

@ -0,0 +1,198 @@
// ===================
// © AngelaMos | 2026
// _reset.scss
// ===================
@use 'tokens' as *;
@use 'fonts' as *;
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
html {
font-size: 16px;
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
overflow-x: hidden;
}
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
scroll-behavior: smooth;
}
}
body {
min-height: 100vh;
min-height: 100dvh;
line-height: $line-height-normal;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
background-color: $bg-default;
color: $text-default;
font-family: $font-sans;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: $line-height-tight;
text-wrap: balance;
overflow-wrap: break-word;
font-weight: $font-weight-semibold;
}
p {
text-wrap: pretty;
overflow-wrap: break-word;
}
ul,
ol {
list-style: none;
}
a {
color: inherit;
text-decoration: none;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
input[type='text'],
input[type='email'],
input[type='password'],
input[type='search'],
input[type='number'],
input[type='tel'],
input[type='url'],
textarea,
select {
font-size: $font-size-sm;
appearance: none;
}
button {
background: none;
border: none;
cursor: pointer;
text-align: inherit;
font-family: inherit;
}
fieldset {
border: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
textarea:not([rows]) {
min-height: 10em;
}
:target {
scroll-margin-block: 5ex;
}
:focus-visible {
outline: 2px solid $border-strong;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
[hidden] {
display: none !important;
}
[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
dialog {
padding: 0;
border: none;
background: transparent;
}
summary {
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@supports (padding: max(0px)) {
body {
padding-left: max(0px, env(safe-area-inset-left));
padding-right: max(0px, env(safe-area-inset-right));
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border-default;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: $border-strong;
}
::selection {
background-color: $bg-selection;
}

View File

@ -0,0 +1,177 @@
// ===================
// © AngelaMos | 2026
// _tokens.scss
// ===================
// ============================================================================
// SPACING (8px base system)
// ============================================================================
$space-0: 0;
$space-px: 1px;
$space-0-5: 0.125rem;
$space-1: 0.25rem;
$space-1-5: 0.375rem;
$space-2: 0.5rem;
$space-2-5: 0.625rem;
$space-3: 0.75rem;
$space-3-5: 0.875rem;
$space-4: 1rem;
$space-5: 1.25rem;
$space-6: 1.5rem;
$space-7: 1.75rem;
$space-8: 2rem;
$space-9: 2.25rem;
$space-10: 2.5rem;
$space-11: 2.75rem;
$space-12: 3rem;
$space-14: 3.5rem;
$space-16: 4rem;
$space-20: 5rem;
$space-24: 6rem;
$space-28: 7rem;
$space-32: 8rem;
// ============================================================================
// TYPOGRAPHY SCALE
// ============================================================================
$font-size-3xs: 0.625rem;
$font-size-2xs: 0.6875rem;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 1.875rem;
$font-size-4xl: 2.25rem;
$font-size-5xl: 3rem;
$font-size-6xl: 3.75rem;
$font-size-7xl: 5rem;
$font-size-8xl: 6.5rem;
// ============================================================================
// FONT WEIGHTS
// ============================================================================
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-black: 900;
// ============================================================================
// LINE HEIGHTS
// ============================================================================
$line-height-none: 1;
$line-height-tight: 1.2;
$line-height-snug: 1.375;
$line-height-normal: 1.5;
$line-height-relaxed: 1.625;
// ============================================================================
// LETTER SPACING
// ============================================================================
$tracking-tighter: -0.05em;
$tracking-tight: -0.025em;
$tracking-normal: 0;
$tracking-wide: 0.025em;
$tracking-wider: 0.05em;
$tracking-widest: 0.1em;
// ============================================================================
// COLORS
// ============================================================================
$white: hsl(0, 0%, 100%);
$black: hsl(0, 0%, 0%);
// Auth
$bg-page: hsl(0, 0%, 10.5%);
$bg-card: hsl(0, 0%, 6.2%);
// Home/landing
$bg-landing: hsl(0, 0%, 10.8%);
$bg-default: hsl(0, 0%, 7.1%);
$bg-alternative: hsl(0, 0%, 5.9%);
$bg-surface-75: hsl(0, 0%, 9%);
$bg-surface-100: hsl(0, 0%, 12.2%);
$bg-surface-200: hsl(0, 0%, 14.1%);
$bg-surface-300: hsl(0, 0%, 16.1%);
$bg-control: hsl(0, 0%, 10%);
$bg-selection: hsl(0, 0%, 19.2%);
$bg-overlay: hsl(0, 0%, 14.1%);
$bg-overlay-hover: hsl(0, 0%, 18%);
$border-muted: hsl(0, 0%, 11.1%);
$border-default: hsl(0, 0%, 18%);
$border-strong: hsl(0, 0%, 22.4%);
$border-stronger: hsl(0, 0%, 27.1%);
$border-control: hsl(0, 0%, 22.4%);
$text-default: hsl(0, 0%, 98%);
$text-light: hsl(0, 0%, 70.6%);
$text-lighter: hsl(0, 0%, 53.7%);
$text-muted: hsl(0, 0%, 30.2%);
$error-default: hsl(0, 72%, 51%);
$error-light: hsl(0, 72%, 65%);
$accent: hsl(25, 85%, 55%);
$accent-dim: hsl(25, 35%, 14%);
// ============================================================================
// BORDER RADIUS
// ============================================================================
$radius-none: 0;
$radius-xs: 2px;
$radius-sm: 4px;
$radius-md: 6px;
$radius-lg: 8px;
$radius-xl: 12px;
$radius-full: 9999px;
// ============================================================================
// Z-INDEX SCALE
// ============================================================================
$z-hide: -1;
$z-base: 0;
$z-dropdown: 100;
$z-sticky: 200;
$z-fixed: 300;
$z-overlay: 400;
$z-modal: 500;
$z-popover: 600;
$z-tooltip: 700;
$z-toast: 800;
$z-max: 9999;
// ============================================================================
// TRANSITIONS
// ============================================================================
$duration-instant: 0ms;
$duration-fast: 100ms;
$duration-normal: 150ms;
$duration-slow: 200ms;
$ease-out: cubic-bezier(0, 0, 0.2, 1);
$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
// ============================================================================
// BREAKPOINTS
// ============================================================================
$breakpoint-xs: 360px;
$breakpoint-sm: 480px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
// ============================================================================
// CONTAINER WIDTHS
// ============================================================================
$container-xs: 20rem;
$container-sm: 24rem;
$container-md: 28rem;
$container-lg: 32rem;
$container-xl: 36rem;
$container-2xl: 42rem;
$container-full: 100%;

View File

@ -0,0 +1,107 @@
// ©AngelaMos | 2025
// stylelint.config.js
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
rules: {
'block-no-empty': true,
'declaration-no-important': true,
'color-no-invalid-hex': true,
'property-no-unknown': true,
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
'selector-class-pattern': [
'^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$',
{
message:
'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)',
},
],
'value-keyword-case': [
'lower',
{
camelCaseSvgKeywords: true,
ignoreKeywords: [
'BlinkMacSystemFont',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'Roboto',
'Arial',
'Helvetica',
'Times',
'Georgia',
'Verdana',
'Tahoma',
'Trebuchet',
'Impact',
'Comic',
],
},
],
'property-no-vendor-prefix': [
true,
{
ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'],
},
],
'value-no-vendor-prefix': true,
'selector-no-vendor-prefix': true,
'property-no-deprecated': [
true,
{
ignoreProperties: ['clip'],
},
],
'container-name-pattern': null,
'layer-name-pattern': null,
'scss/at-rule-no-unknown': true,
'scss/declaration-nested-properties-no-divided-groups': true,
'scss/dollar-variable-no-missing-interpolation': true,
'scss/dollar-variable-empty-line-before': null,
'declaration-empty-line-before': null,
'custom-property-empty-line-before': null,
'no-descending-specificity': null,
'media-feature-name-no-unknown': [
true,
{
ignoreMediaFeatureNames: ['map'],
},
],
'color-function-notation': null,
'hue-degree-notation': null,
},
ignoreFiles: [
'node_modules/**',
'dist/**',
'build/**',
'**/*.js',
'**/*.ts',
'**/*.tsx',
],
overrides: [
{
files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'],
rules: {
'declaration-no-important': null,
'scss/comment-no-empty': null,
},
},
],
}

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,71 @@
/**
* ©AngelaMos | 2025
* vite.config.ts
*/
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.resolve(__dirname, '..'), '')
const isDev = mode === 'development'
return {
plugins: [react(), tsconfigPaths()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
css: {
preprocessorOptions: {
scss: {},
},
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: env.VITE_API_TARGET || 'http://localhost:8000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
build: {
target: 'esnext',
cssTarget: 'chrome100',
sourcemap: isDev ? true : 'hidden',
minify: 'oxc',
rollupOptions: {
output: {
manualChunks(id: string): string | undefined {
if (id.includes('node_modules')) {
if (id.includes('react-dom') || id.includes('react-router')) {
return 'vendor-react'
}
if (id.includes('@tanstack/react-query')) {
return 'vendor-query'
}
if (id.includes('zustand')) {
return 'vendor-state'
}
}
return undefined
},
},
},
},
preview: {
port: 4173,
},
}
})

View File

@ -0,0 +1,18 @@
# ©AngelaMos | 2026
# rust.dev
# Development Dockerfile for Rust backend with cargo-watch hot reload
# syntax=docker/dockerfile:1
FROM rust:1.88-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config libssl-dev libpq-dev && \
rm -rf /var/lib/apt/lists/* && \
cargo install cargo-watch
WORKDIR /app
EXPOSE 3000
CMD ["cargo", "watch", "-x", "run", "-w", "crates"]

View File

@ -0,0 +1,46 @@
# ©AngelaMos | 2026
# rust.prod
# Production multi-stage Dockerfile for Rust backend
# syntax=docker/dockerfile:1
# ============================================================================
# BUILD STAGE
# ============================================================================
FROM rust:1.88-slim AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config libssl-dev libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY backend/Cargo.toml backend/Cargo.lock* ./
COPY backend/crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release && \
cp target/release/axumortem /usr/local/bin/axumortem
# ============================================================================
# RUNTIME STAGE
# ============================================================================
FROM debian:bookworm-slim AS runtime
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpq5 ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
useradd --create-home --shell /bin/false axumortem
COPY --from=builder /usr/local/bin/axumortem /usr/local/bin/axumortem
USER axumortem
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/axumortem", "--help"] || exit 1
ENTRYPOINT ["/usr/local/bin/axumortem"]

View File

@ -0,0 +1,25 @@
# =============================================================================
# AngelaMos | 2026
# vite.dev
# =============================================================================
# Development Dockerfile for Vite/React frontend
# Features: pnpm, HMR support, polling for Docker file watching
# =============================================================================
# syntax=docker/dockerfile:1
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
EXPOSE 5173
CMD ["pnpm", "dev", "--host", "0.0.0.0"]

Some files were not shown because too many files have changed in this diff Show More