Skip to main content

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}