pub mod checks;
pub mod output;
use anyhow::{bail, Context, Result};
use goblin::{Object, mach};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::PathBuf};
use walkdir::WalkDir;
pub use output::{print_report, OutputFormat};
#[derive(Debug, Clone)]
pub enum FileFilter {
All,
WindowsExecutables,
WindowsDlls,
WindowsExecutablesAndDlls,
Extensions(Vec<String>),
Custom(fn(&std::path::Path) -> bool),
}
#[derive(Debug, Clone)]
pub struct ScanOptions {
pub recursive: bool,
pub issues_only: bool,
pub strict: bool,
pub file_filter: FileFilter,
pub one_filesystem: bool,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
recursive: false,
issues_only: false,
strict: false,
file_filter: FileFilter::All,
one_filesystem: false,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SecurityCheck {
pub file_path: String,
pub file_type: String,
pub checks: HashMap<String, String>,
pub overall_status: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SecurityReport {
pub files: Vec<SecurityCheck>,
pub summary: ReportSummary,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ReportSummary {
pub total_files: usize,
pub secure_files: usize,
pub insecure_files: usize,
pub unsupported_files: usize,
}
pub fn analyze_file(path: &PathBuf) -> Result<SecurityCheck> {
let data = fs::read(path).with_context(|| format!("reading {:?}", path))?;
match Object::parse(&data).context("parsing object")? {
Object::Elf(elf) => checks::analyze_elf(path, &elf, &data),
Object::PE(pe) => checks::analyze_pe(path, &pe, &data),
Object::Mach(mach) => match mach {
mach::Mach::Fat(fat) => checks::analyze_macho_fat(path, &fat, &data),
mach::Mach::Binary(macho) => checks::analyze_macho(path, &macho, &data),
},
other => bail!("Unsupported file type: {other:?}"),
}
}
pub fn scan_directory(dir_path: &PathBuf, options: &ScanOptions) -> Result<SecurityReport> {
let files = collect_executable_files(dir_path, options)?;
analyze_files(files, options)
}
pub fn analyze_files(files: Vec<PathBuf>, options: &ScanOptions) -> Result<SecurityReport> {
let mut security_checks = Vec::new();
let mut secure_count = 0;
let mut insecure_count = 0;
let mut unsupported_count = 0;
for file_path in files {
match analyze_file(&file_path) {
Ok(check) => {
let is_secure = check.overall_status == "Secure";
if is_secure {
secure_count += 1;
} else {
insecure_count += 1;
}
if !options.issues_only || !is_secure {
security_checks.push(check);
}
}
Err(_) => {
unsupported_count += 1;
if !options.issues_only {
security_checks.push(SecurityCheck {
file_path: file_path.display().to_string(),
file_type: "Unknown".to_string(),
checks: HashMap::new(),
overall_status: "Unsupported".to_string(),
});
}
}
}
}
Ok(SecurityReport {
files: security_checks,
summary: ReportSummary {
total_files: secure_count + insecure_count + unsupported_count,
secure_files: secure_count,
insecure_files: insecure_count,
unsupported_files: unsupported_count,
},
})
}
pub fn is_executable_file(path: &std::path::Path) -> Result<bool> {
let data = match fs::read(path) {
Ok(data) if data.len() >= 4 => data,
_ => return Ok(false),
};
if data.starts_with(b"\x7fELF") || data.starts_with(b"MZ") || data.starts_with(&[0xFE, 0xED, 0xFA, 0xCE]) || data.starts_with(&[0xCE, 0xFA, 0xED, 0xFE]) || data.starts_with(&[0xFE, 0xED, 0xFA, 0xCF]) || data.starts_with(&[0xCF, 0xFA, 0xED, 0xFE]) { return Ok(true);
}
Ok(false)
}
pub fn matches_file_filter(path: &std::path::Path, filter: &FileFilter) -> Result<bool> {
match filter {
FileFilter::All => is_executable_file(path),
FileFilter::WindowsExecutables => {
if let Some(ext) = path.extension() {
if ext.to_string_lossy().to_lowercase() == "exe" {
return is_executable_file(path);
}
}
Ok(false)
}
FileFilter::WindowsDlls => {
if let Some(ext) = path.extension() {
if ext.to_string_lossy().to_lowercase() == "dll" {
return is_executable_file(path);
}
}
Ok(false)
}
FileFilter::WindowsExecutablesAndDlls => {
if let Some(ext) = path.extension() {
let ext_lower = ext.to_string_lossy().to_lowercase();
if ext_lower == "exe" || ext_lower == "dll" {
return is_executable_file(path);
}
}
Ok(false)
}
FileFilter::Extensions(extensions) => {
if let Some(ext) = path.extension() {
let ext_lower = ext.to_string_lossy().to_lowercase();
if extensions.iter().any(|e| e.to_lowercase() == ext_lower) {
return is_executable_file(path);
}
}
Ok(false)
}
FileFilter::Custom(predicate) => {
if predicate(path) {
return is_executable_file(path);
}
Ok(false)
}
}
}
pub fn collect_executable_files(dir: &PathBuf, options: &ScanOptions) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut walker = WalkDir::new(dir);
if !options.recursive {
walker = walker.max_depth(1);
}
#[cfg(unix)]
let root_dev = if options.one_filesystem {
use std::os::unix::fs::MetadataExt;
Some(fs::metadata(dir)
.context("getting root directory metadata")?
.dev())
} else {
None
};
#[cfg(not(unix))]
let root_dev: Option<()> = None;
for entry in walker {
let entry = entry.context("walking directory")?;
let path = entry.path();
#[cfg(unix)]
if let Some(expected_dev) = root_dev {
use std::os::unix::fs::MetadataExt;
if let Ok(metadata) = entry.metadata() {
if metadata.dev() != expected_dev {
continue; }
}
}
if path.is_file() && matches_file_filter(path, &options.file_filter)? {
files.push(path.to_path_buf());
}
}
Ok(files)
}
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn version_info() -> String {
format!(
"execheck {} (goblin {}, built with rustc)",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_VERSION_MAJOR")
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_version() {
assert!(!version().is_empty());
assert!(version_info().contains("execheck"));
}
#[test]
fn test_scan_options_default() {
let options = ScanOptions::default();
assert!(!options.recursive);
assert!(!options.issues_only);
assert!(!options.strict);
}
#[test]
fn test_is_executable_file_nonexistent() {
let result = is_executable_file(&PathBuf::from("/nonexistent/file").as_path());
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_security_check_creation() {
let check = SecurityCheck {
file_path: "/test/file".to_string(),
file_type: "ELF".to_string(),
checks: HashMap::new(),
overall_status: "Secure".to_string(),
};
assert_eq!(check.file_path, "/test/file");
assert_eq!(check.file_type, "ELF");
assert_eq!(check.overall_status, "Secure");
}
#[test]
fn test_file_filter_all() {
use std::path::Path;
let result = matches_file_filter(Path::new("/nonexistent/test.exe"), &FileFilter::All);
assert!(result.is_ok()); }
#[test]
fn test_file_filter_windows_exe() {
use std::path::Path;
let path_exe = Path::new("/test/file.exe");
let path_txt = Path::new("/test/file.txt");
let result_exe = matches_file_filter(path_exe, &FileFilter::WindowsExecutables);
let result_txt = matches_file_filter(path_txt, &FileFilter::WindowsExecutables);
assert!(result_exe.is_ok());
assert!(result_txt.is_ok());
}
#[test]
fn test_file_filter_extensions() {
use std::path::Path;
let extensions = vec!["exe".to_string(), "dll".to_string(), "so".to_string()];
let filter = FileFilter::Extensions(extensions);
let result = matches_file_filter(Path::new("/test/file.exe"), &filter);
assert!(result.is_ok());
let result = matches_file_filter(Path::new("/test/file.txt"), &filter);
assert!(result.is_ok());
}
#[test]
fn test_scan_options_with_filters() {
let options = ScanOptions {
recursive: true,
issues_only: false,
strict: false,
file_filter: FileFilter::WindowsExecutables,
one_filesystem: true,
};
assert!(options.recursive);
assert!(options.one_filesystem);
assert!(!options.issues_only);
assert!(!options.strict);
}
#[test]
fn test_scan_options_default_includes_new_fields() {
let options = ScanOptions::default();
assert!(!options.recursive);
assert!(!options.issues_only);
assert!(!options.strict);
assert!(!options.one_filesystem);
}
}