project binary-analysis-tool complete
This commit is contained in:
parent
f1d6dc4b32
commit
0bfdd8d247
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
```rust
|
||||
█████╗ ██╗ ██╗██╗ ██╗███╗ ███╗ ██████╗ ██████╗ ████████╗███████╗███╗ ███╗
|
||||
██╔══██╗╚██╗██╔╝██║ ██║████╗ ████║██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝████╗ ████║
|
||||
███████║ ╚███╔╝ ██║ ██║██╔████╔██║██║ ██║██████╔╝ ██║ █████╗ ██╔████╔██║
|
||||
██╔══██║ ██╔██╗ ██║ ██║██║╚██╔╝██║██║ ██║██╔══██╗ ██║ ██╔══╝ ██║╚██╔╝██║
|
||||
██║ ██║██╔╝ ██╗╚██████╔╝██║ ╚═╝ ██║╚██████╔╝██║ ██║ ██║ ███████╗██║ ╚═╝ ██║
|
||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/intermediate/binary-analysis-tool)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](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
|
|
@ -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"
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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(
|
||||
§ions,
|
||||
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
|
||||
}
|
||||
|
|
@ -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(
|
||||
§ions,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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(§ion.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
|
||||
}
|
||||
|
|
@ -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(
|
||||
§ions,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
¤t_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(
|
||||
¤t_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(
|
||||
¤t_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,
|
||||
§ions,
|
||||
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,
|
||||
§ions,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(§ion.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, §ions, 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, §ions, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(§ions, 50);
|
||||
assert_eq!(found, Some(".rodata".into()));
|
||||
|
||||
let not_found = find_section(§ions, 200);
|
||||
assert_eq!(not_found, None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/fixtures/hello_elf
vendored
Executable file
BIN
PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/fixtures/hello_elf
vendored
Executable file
Binary file not shown.
BIN
PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/fixtures/hello_elf_stripped
vendored
Executable file
BIN
PROJECTS/intermediate/binary-analysis-tool/backend/crates/axumortem-engine/tests/fixtures/hello_elf_stripped
vendored
Executable file
Binary file not shown.
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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);
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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")])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ©AngelaMos | 2026
|
||||
// mod.rs
|
||||
|
||||
pub mod cors;
|
||||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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"}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './hooks'
|
||||
export * from './schemas'
|
||||
export * from './types'
|
||||
|
|
@ -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(),
|
||||
}),
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './api.config'
|
||||
export * from './errors'
|
||||
export * from './query.config'
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './format'
|
||||
export * from './shell.ui.store'
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>© ANGELAMOS 2026</span>
|
||||
<span>AXUMORTEM</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Landing'
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
Loading…
Reference in New Issue