execheck/lib.rs
1//! # ExeCheck - Multi-Platform Executable Security Checker
2//!
3//! ExeCheck is a comprehensive security analysis library for executable files across multiple platforms:
4//! - Linux ELF binaries
5//! - Windows PE executables
6//! - macOS Mach-O binaries
7//!
8//! ## Features
9//!
10//! - **Comprehensive Security Analysis**: Checks for stack canaries, NX/DEP, PIE/ASLR, RELRO, and more
11//! - **Multi-Platform Support**: Unified API for analyzing different executable formats
12//! - **Multiple Output Formats**: Human-readable, JSON, YAML, XML, and CSV output
13//! - **Directory Scanning**: Recursive directory analysis with filtering options
14//! - **Library and CLI**: Use as a Rust library or standalone command-line tool
15//!
16//! ## Quick Start
17//!
18//! ### Library Usage
19//!
20//! ```rust
21//! use execheck::{analyze_file, OutputFormat, print_report};
22//! use std::path::PathBuf;
23//!
24//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
25//! // Analyze a single file
26//! let file_path = PathBuf::from("/bin/ls");
27//! let result = analyze_file(&file_path)?;
28//!
29//! println!("File: {}", result.file_path);
30//! println!("Type: {}", result.file_type);
31//! println!("Status: {}", result.overall_status);
32//!
33//! // Print detailed report
34//! let report = execheck::SecurityReport {
35//! files: vec![result],
36//! summary: execheck::ReportSummary {
37//! total_files: 1,
38//! secure_files: 1,
39//! insecure_files: 0,
40//! unsupported_files: 0,
41//! },
42//! };
43//!
44//! print_report(&report, &OutputFormat::Human, None)?;
45//! # Ok(())
46//! # }
47//! ```
48//!
49//! ### Batch Analysis
50//!
51//! ```rust
52//! use execheck::{scan_directory, ScanOptions, FileFilter};
53//! use std::path::PathBuf;
54//!
55//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
56//! let options = ScanOptions {
57//! recursive: true,
58//! issues_only: true,
59//! strict: false,
60//! file_filter: FileFilter::All,
61//! one_filesystem: false,
62//! };
63//!
64//! let report = scan_directory(&PathBuf::from("/usr/bin"), &options)?;
65//! println!("Scanned {} files", report.summary.total_files);
66//! println!("Found issues in {} files", report.summary.insecure_files);
67//! # Ok(())
68//! # }
69//! ```
70
71pub mod checks;
72pub mod output;
73
74use anyhow::{bail, Context, Result};
75use goblin::{Object, mach};
76use serde::{Deserialize, Serialize};
77use std::{collections::HashMap, fs, path::PathBuf};
78use walkdir::WalkDir;
79
80// Re-export commonly used types
81pub use output::{print_report, OutputFormat};
82
83/// File type filters for directory scanning
84#[derive(Debug, Clone)]
85pub enum FileFilter {
86 /// All executable files (default behavior)
87 All,
88 /// Only Windows executables (.exe files)
89 WindowsExecutables,
90 /// Only Windows dynamic libraries (.dll files)
91 WindowsDlls,
92 /// Both .exe and .dll files
93 WindowsExecutablesAndDlls,
94 /// Custom file extension filter
95 Extensions(Vec<String>),
96 /// Custom predicate function
97 Custom(fn(&std::path::Path) -> bool),
98}
99
100/// Configuration options for directory scanning
101#[derive(Debug, Clone)]
102pub struct ScanOptions {
103 /// Enable recursive directory traversal
104 pub recursive: bool,
105 /// Only return files with security issues
106 pub issues_only: bool,
107 /// Enable strict mode (affects error handling)
108 pub strict: bool,
109 /// File type filter for scanning
110 pub file_filter: FileFilter,
111 /// Stay within single filesystem (Unix-like systems only)
112 pub one_filesystem: bool,
113}
114
115impl Default for ScanOptions {
116 fn default() -> Self {
117 Self {
118 recursive: false,
119 issues_only: false,
120 strict: false,
121 file_filter: FileFilter::All,
122 one_filesystem: false,
123 }
124 }
125}
126
127/// Security analysis result for a single executable file
128#[derive(Serialize, Deserialize, Debug, Clone)]
129pub struct SecurityCheck {
130 /// Path to the analyzed file
131 pub file_path: String,
132 /// File type (ELF, PE, Mach-O, etc.)
133 pub file_type: String,
134 /// Map of security check names to their results
135 pub checks: HashMap<String, String>,
136 /// Overall security status (Secure, Mostly Secure, Insecure, etc.)
137 pub overall_status: String,
138}
139
140/// Complete security analysis report for multiple files
141#[derive(Serialize, Deserialize, Debug)]
142pub struct SecurityReport {
143 /// Individual file analysis results
144 pub files: Vec<SecurityCheck>,
145 /// Summary statistics
146 pub summary: ReportSummary,
147}
148
149/// Summary statistics for a security report
150#[derive(Serialize, Deserialize, Debug)]
151pub struct ReportSummary {
152 /// Total number of files analyzed
153 pub total_files: usize,
154 /// Number of files with good security posture
155 pub secure_files: usize,
156 /// Number of files with security issues
157 pub insecure_files: usize,
158 /// Number of files that couldn't be analyzed
159 pub unsupported_files: usize,
160}
161
162/// Analyze a single executable file for security features
163///
164/// This function detects the executable format and performs appropriate security analysis.
165///
166/// # Arguments
167///
168/// * `path` - Path to the executable file to analyze
169///
170/// # Returns
171///
172/// Returns a `SecurityCheck` struct containing the analysis results, or an error if
173/// the file cannot be read or is not a supported executable format.
174///
175/// # Example
176///
177/// ```rust
178/// use execheck::analyze_file;
179/// use std::path::PathBuf;
180///
181/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
182/// let result = analyze_file(&PathBuf::from("/bin/ls"))?;
183/// println!("Security status: {}", result.overall_status);
184/// # Ok(())
185/// # }
186/// ```
187pub fn analyze_file(path: &PathBuf) -> Result<SecurityCheck> {
188 let data = fs::read(path).with_context(|| format!("reading {:?}", path))?;
189
190 match Object::parse(&data).context("parsing object")? {
191 Object::Elf(elf) => checks::analyze_elf(path, &elf, &data),
192 Object::PE(pe) => checks::analyze_pe(path, &pe, &data),
193 Object::Mach(mach) => match mach {
194 mach::Mach::Fat(fat) => checks::analyze_macho_fat(path, &fat, &data),
195 mach::Mach::Binary(macho) => checks::analyze_macho(path, &macho, &data),
196 },
197 other => bail!("Unsupported file type: {other:?}"),
198 }
199}
200
201/// Scan a directory for executable files and analyze their security features
202///
203/// This function recursively scans a directory for executable files and analyzes each one.
204/// The behavior can be customized using the `ScanOptions` parameter.
205///
206/// # Arguments
207///
208/// * `dir_path` - Path to the directory to scan
209/// * `options` - Scanning options (recursive, issues_only, strict)
210///
211/// # Returns
212///
213/// Returns a `SecurityReport` containing analysis results for all found executables.
214///
215/// # Example
216///
217/// ```rust
218/// use execheck::{scan_directory, ScanOptions, FileFilter};
219/// use std::path::PathBuf;
220///
221/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
222/// let options = ScanOptions {
223/// recursive: true,
224/// issues_only: false,
225/// strict: false,
226/// file_filter: FileFilter::All,
227/// one_filesystem: false,
228/// };
229///
230/// let report = scan_directory(&PathBuf::from("/usr/bin"), &options)?;
231/// println!("Found {} executable files", report.summary.total_files);
232/// # Ok(())
233/// # }
234/// ```
235pub fn scan_directory(dir_path: &PathBuf, options: &ScanOptions) -> Result<SecurityReport> {
236 let files = collect_executable_files(dir_path, options)?;
237 analyze_files(files, options)
238}
239
240/// Analyze multiple files and generate a security report
241///
242/// # Arguments
243///
244/// * `files` - Vector of file paths to analyze
245/// * `options` - Scanning options that affect filtering
246///
247/// # Returns
248///
249/// Returns a complete `SecurityReport` with summary statistics.
250pub fn analyze_files(files: Vec<PathBuf>, options: &ScanOptions) -> Result<SecurityReport> {
251 let mut security_checks = Vec::new();
252 let mut secure_count = 0;
253 let mut insecure_count = 0;
254 let mut unsupported_count = 0;
255
256 for file_path in files {
257 match analyze_file(&file_path) {
258 Ok(check) => {
259 let is_secure = check.overall_status == "Secure";
260 if is_secure {
261 secure_count += 1;
262 } else {
263 insecure_count += 1;
264 }
265
266 if !options.issues_only || !is_secure {
267 security_checks.push(check);
268 }
269 }
270 Err(_) => {
271 unsupported_count += 1;
272 if !options.issues_only {
273 security_checks.push(SecurityCheck {
274 file_path: file_path.display().to_string(),
275 file_type: "Unknown".to_string(),
276 checks: HashMap::new(),
277 overall_status: "Unsupported".to_string(),
278 });
279 }
280 }
281 }
282 }
283
284 Ok(SecurityReport {
285 files: security_checks,
286 summary: ReportSummary {
287 total_files: secure_count + insecure_count + unsupported_count,
288 secure_files: secure_count,
289 insecure_files: insecure_count,
290 unsupported_files: unsupported_count,
291 },
292 })
293}
294
295/// Check if a file appears to be an executable based on magic bytes
296///
297/// This function performs a quick check of the file header to identify common
298/// executable formats without fully parsing the file.
299///
300/// # Arguments
301///
302/// * `path` - Path to the file to check
303///
304/// # Returns
305///
306/// Returns `true` if the file appears to be an executable, `false` otherwise.
307pub fn is_executable_file(path: &std::path::Path) -> Result<bool> {
308 let data = match fs::read(path) {
309 Ok(data) if data.len() >= 4 => data,
310 _ => return Ok(false),
311 };
312
313 // Check for common executable headers
314 if data.starts_with(b"\x7fELF") || // ELF
315 data.starts_with(b"MZ") || // PE/DOS
316 data.starts_with(&[0xFE, 0xED, 0xFA, 0xCE]) || // Mach-O 32-bit big endian
317 data.starts_with(&[0xCE, 0xFA, 0xED, 0xFE]) || // Mach-O 32-bit little endian
318 data.starts_with(&[0xFE, 0xED, 0xFA, 0xCF]) || // Mach-O 64-bit big endian
319 data.starts_with(&[0xCF, 0xFA, 0xED, 0xFE]) { // Mach-O 64-bit little endian
320 return Ok(true);
321 }
322
323 Ok(false)
324}
325
326/// Check if a file matches the specified file filter
327///
328/// This function applies custom filtering logic based on the FileFilter enum.
329///
330/// # Arguments
331///
332/// * `path` - Path to the file to check
333/// * `filter` - The file filter to apply
334///
335/// # Returns
336///
337/// Returns `true` if the file matches the filter, `false` otherwise.
338pub fn matches_file_filter(path: &std::path::Path, filter: &FileFilter) -> Result<bool> {
339 match filter {
340 FileFilter::All => is_executable_file(path),
341 FileFilter::WindowsExecutables => {
342 if let Some(ext) = path.extension() {
343 if ext.to_string_lossy().to_lowercase() == "exe" {
344 return is_executable_file(path);
345 }
346 }
347 Ok(false)
348 }
349 FileFilter::WindowsDlls => {
350 if let Some(ext) = path.extension() {
351 if ext.to_string_lossy().to_lowercase() == "dll" {
352 return is_executable_file(path);
353 }
354 }
355 Ok(false)
356 }
357 FileFilter::WindowsExecutablesAndDlls => {
358 if let Some(ext) = path.extension() {
359 let ext_lower = ext.to_string_lossy().to_lowercase();
360 if ext_lower == "exe" || ext_lower == "dll" {
361 return is_executable_file(path);
362 }
363 }
364 Ok(false)
365 }
366 FileFilter::Extensions(extensions) => {
367 if let Some(ext) = path.extension() {
368 let ext_lower = ext.to_string_lossy().to_lowercase();
369 if extensions.iter().any(|e| e.to_lowercase() == ext_lower) {
370 return is_executable_file(path);
371 }
372 }
373 Ok(false)
374 }
375 FileFilter::Custom(predicate) => {
376 if predicate(path) {
377 return is_executable_file(path);
378 }
379 Ok(false)
380 }
381 }
382}
383
384/// Collect all executable files from a directory
385///
386/// This function walks through a directory and identifies executable files based on
387/// their magic bytes and the specified file filter. It can operate recursively or
388/// just scan the top level, and can respect filesystem boundaries.
389///
390/// # Arguments
391///
392/// * `dir` - Directory path to scan
393/// * `options` - Scan options including recursion, filtering, and filesystem boundaries
394///
395/// # Returns
396///
397/// Returns a vector of paths to executable files found in the directory.
398pub fn collect_executable_files(dir: &PathBuf, options: &ScanOptions) -> Result<Vec<PathBuf>> {
399 let mut files = Vec::new();
400
401 let mut walker = WalkDir::new(dir);
402
403 if !options.recursive {
404 walker = walker.max_depth(1);
405 }
406
407 // Handle filesystem boundaries on Unix-like systems
408 #[cfg(unix)]
409 let root_dev = if options.one_filesystem {
410 use std::os::unix::fs::MetadataExt;
411 Some(fs::metadata(dir)
412 .context("getting root directory metadata")?
413 .dev())
414 } else {
415 None
416 };
417
418 #[cfg(not(unix))]
419 let root_dev: Option<()> = None;
420
421 for entry in walker {
422 let entry = entry.context("walking directory")?;
423 let path = entry.path();
424
425 // Check filesystem boundaries on Unix systems
426 #[cfg(unix)]
427 if let Some(expected_dev) = root_dev {
428 use std::os::unix::fs::MetadataExt;
429 if let Ok(metadata) = entry.metadata() {
430 if metadata.dev() != expected_dev {
431 continue; // Skip files on different filesystems
432 }
433 }
434 }
435
436 if path.is_file() && matches_file_filter(path, &options.file_filter)? {
437 files.push(path.to_path_buf());
438 }
439 }
440
441 Ok(files)
442}
443
444
445/// Get version information for the library
446pub fn version() -> &'static str {
447 env!("CARGO_PKG_VERSION")
448}
449
450/// Get detailed version information including dependencies
451pub fn version_info() -> String {
452 format!(
453 "execheck {} (goblin {}, built with rustc)",
454 env!("CARGO_PKG_VERSION"),
455 env!("CARGO_PKG_VERSION_MAJOR")
456 )
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use std::path::PathBuf;
463
464 #[test]
465 fn test_version() {
466 assert!(!version().is_empty());
467 assert!(version_info().contains("execheck"));
468 }
469
470 #[test]
471 fn test_scan_options_default() {
472 let options = ScanOptions::default();
473 assert!(!options.recursive);
474 assert!(!options.issues_only);
475 assert!(!options.strict);
476 }
477
478 #[test]
479 fn test_is_executable_file_nonexistent() {
480 let result = is_executable_file(&PathBuf::from("/nonexistent/file").as_path());
481 assert!(result.is_ok());
482 assert!(!result.unwrap());
483 }
484
485 #[test]
486 fn test_security_check_creation() {
487 let check = SecurityCheck {
488 file_path: "/test/file".to_string(),
489 file_type: "ELF".to_string(),
490 checks: HashMap::new(),
491 overall_status: "Secure".to_string(),
492 };
493
494 assert_eq!(check.file_path, "/test/file");
495 assert_eq!(check.file_type, "ELF");
496 assert_eq!(check.overall_status, "Secure");
497 }
498
499 #[test]
500 fn test_file_filter_all() {
501 use std::path::Path;
502
503 // Create temporary test file (this is just testing the logic)
504 let result = matches_file_filter(Path::new("/nonexistent/test.exe"), &FileFilter::All);
505 assert!(result.is_ok()); // Should not error on checking filter logic
506 }
507
508 #[test]
509 fn test_file_filter_windows_exe() {
510 use std::path::Path;
511
512 // Test .exe extension matching logic (not actual file content)
513 let path_exe = Path::new("/test/file.exe");
514 let path_txt = Path::new("/test/file.txt");
515
516 // The function will return Ok(false) for non-existent files after checking extension
517 let result_exe = matches_file_filter(path_exe, &FileFilter::WindowsExecutables);
518 let result_txt = matches_file_filter(path_txt, &FileFilter::WindowsExecutables);
519
520 assert!(result_exe.is_ok());
521 assert!(result_txt.is_ok());
522 }
523
524 #[test]
525 fn test_file_filter_extensions() {
526 use std::path::Path;
527
528 let extensions = vec!["exe".to_string(), "dll".to_string(), "so".to_string()];
529 let filter = FileFilter::Extensions(extensions);
530
531 // Test extension matching logic
532 let result = matches_file_filter(Path::new("/test/file.exe"), &filter);
533 assert!(result.is_ok());
534
535 let result = matches_file_filter(Path::new("/test/file.txt"), &filter);
536 assert!(result.is_ok());
537 }
538
539 #[test]
540 fn test_scan_options_with_filters() {
541 let options = ScanOptions {
542 recursive: true,
543 issues_only: false,
544 strict: false,
545 file_filter: FileFilter::WindowsExecutables,
546 one_filesystem: true,
547 };
548
549 assert!(options.recursive);
550 assert!(options.one_filesystem);
551 assert!(!options.issues_only);
552 assert!(!options.strict);
553 }
554
555 #[test]
556 fn test_scan_options_default_includes_new_fields() {
557 let options = ScanOptions::default();
558 assert!(!options.recursive);
559 assert!(!options.issues_only);
560 assert!(!options.strict);
561 assert!(!options.one_filesystem);
562 // file_filter should be FileFilter::All but we can't easily test enum equality
563 }
564}