Skip to main content

binsec/
lib.rs

1//! Implements the main interface necessary in order to parse binary inputs.
2//! Should be used to detect format and security mitigations for a single binary.
3#![allow(clippy::match_bool)]
4
5use crate::check::{Analyze, GenericMap};
6
7use goblin::mach::Mach;
8use goblin::Object;
9
10use byte_unit::Byte;
11use chrono::{DateTime, Utc};
12use serde_json::{json, Value};
13
14use std::fs::{self, Metadata};
15use std::path::{Path, PathBuf};
16
17pub mod check;
18pub mod sarif;
19
20/// Output format selected on the command line.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
22pub enum Format {
23    /// Human-readable tables (default).
24    Table,
25    /// Pretty-printed JSON report.
26    Json,
27    /// SARIF 2.1.0 static-analysis report (strictly validated before output).
28    Sarif,
29    /// Markdown rendered from the validated SARIF 2.1.0 report.
30    Markdown,
31}
32
33/// Custom error type for all errors types that binsec might encounter
34#[derive(thiserror::Error, Debug)]
35pub enum BinError {
36    #[error("IOError: `{0}`")]
37    Io(#[from] std::io::Error),
38    #[error("libgoblin: `{0}`")]
39    Goblin(#[from] goblin::error::Error),
40    #[error("serde: `{0}`")]
41    Serde(#[from] serde_json::Error),
42    #[error("internal: `{0}`")]
43    Internal(String),
44    #[error("unknown data store error")]
45    Unknown,
46}
47
48pub type BinResult<R> = Result<R, BinError>;
49
50/// Interfaces static analysis and wraps around parsed information for serialization.
51#[derive(serde::Serialize)]
52pub struct Detector {
53    basic: GenericMap,
54    compilation: GenericMap,
55    mitigations: GenericMap,
56    instrumentation: GenericMap,
57}
58
59impl Detector {
60    pub fn run(binpath: PathBuf) -> BinResult<Self> {
61        // metadata shared by every binary format
62        let basic_map: GenericMap = Self::base_metadata(&binpath)?;
63
64        // read raw binary from path
65        let data: Vec<u8> = std::fs::read(&binpath)?;
66
67        // parse executable as format and run format-specific mitigation checks
68        match Object::parse(&data)? {
69            Object::Elf(elf) => Ok(Self {
70                basic: Self::elf_basic(basic_map, &elf),
71                compilation: elf.compilation(&data)?,
72                mitigations: elf.mitigations(),
73                instrumentation: elf.instrumentation(),
74            }),
75            Object::PE(pe) => Ok(Self {
76                basic: Self::pe_basic(basic_map, &pe),
77                compilation: pe.compilation(&data)?,
78                mitigations: pe.mitigations(),
79                instrumentation: pe.instrumentation(),
80            }),
81            Object::Mach(Mach::Binary(mach)) => Ok(Self {
82                basic: Self::mach_basic(basic_map, &mach),
83                compilation: mach.compilation(&data)?,
84                mitigations: mach.mitigations(),
85                instrumentation: mach.instrumentation(),
86            }),
87            bin => Err(BinError::Internal(format!(
88                "unsupported filetype for analysis: {:?}",
89                bin
90            ))),
91        }
92    }
93
94    /// Collect the path / size / last-modified metadata common to every
95    /// supported binary format.
96    fn base_metadata(binpath: &Path) -> BinResult<GenericMap> {
97        let mut basic_map: GenericMap = GenericMap::new();
98
99        // get absolute path to executable
100        let abspath_buf: PathBuf = fs::canonicalize(binpath)?;
101        let abspath: String = abspath_buf
102            .to_str()
103            .ok_or_else(|| BinError::Internal("path is not valid UTF-8".to_string()))?
104            .to_string();
105        basic_map.insert("Absolute Path".to_string(), json!(abspath));
106
107        // parse out initial metadata used in all binary formats
108        let metadata: Metadata = fs::metadata(binpath)?;
109
110        // filesize with readable byte unit
111        let size: u128 = metadata.len() as u128;
112        let byte = Byte::from_bytes(size);
113        let filesize: String = byte.get_appropriate_unit(false).to_string();
114        basic_map.insert("File Size".to_string(), json!(filesize));
115
116        // parse out readable modified timestamp
117        if let Ok(time) = metadata.accessed() {
118            let datetime: DateTime<Utc> = time.into();
119            let stamp: String = datetime.format("%Y-%m-%d %H:%M:%S").to_string();
120            basic_map.insert("Last Modified".to_string(), json!(stamp));
121        }
122        Ok(basic_map)
123    }
124
125    /// Populate the format-agnostic `basic` map with ELF header facts.
126    fn elf_basic(mut basic_map: GenericMap, elf: &goblin::elf::Elf<'_>) -> GenericMap {
127        use goblin::elf::header;
128
129        basic_map.insert("Binary Format".to_string(), json!("ELF"));
130        let arch: String = header::machine_to_str(elf.header.e_machine).to_string();
131        basic_map.insert("Architecture".to_string(), json!(arch));
132        let entry_point: String = format!("0x{:x}", elf.header.e_entry);
133        basic_map.insert("Entry Point Address".to_string(), json!(entry_point));
134        basic_map
135    }
136
137    /// Populate the format-agnostic `basic` map with PE header facts.
138    fn pe_basic(mut basic_map: GenericMap, pe: &goblin::pe::PE<'_>) -> GenericMap {
139        basic_map.insert("Binary Format".to_string(), json!("PE/EXE"));
140        let arch: &str = if pe.is_64 { "PE32+" } else { "PE32" };
141        basic_map.insert("Architecture".to_string(), json!(arch));
142        let entry_point: String = format!("0x{:x}", pe.entry);
143        basic_map.insert("Entry Point Address".to_string(), json!(entry_point));
144        basic_map
145    }
146
147    /// Populate the format-agnostic `basic` map with Mach-O header facts.
148    fn mach_basic(mut basic_map: GenericMap, mach: &goblin::mach::MachO<'_>) -> GenericMap {
149        use goblin::mach::constants::cputype;
150        use goblin::mach::load_command::CommandVariant;
151
152        basic_map.insert("Binary Format".to_string(), json!("Mach-O"));
153        let cputype: &str = match mach.header.cputype() {
154            cputype::CPU_TYPE_I386 => "i386",
155            cputype::CPU_TYPE_X86_64 => "x86_64",
156            cputype::CPU_TYPE_ARM => "arm",
157            cputype::CPU_TYPE_ARM64 => "arm64",
158            _ => "<unknown>",
159        };
160        basic_map.insert("Architecture".to_string(), json!(cputype));
161
162        for cmd in &mach.load_commands {
163            if let CommandVariant::Main(entry) = cmd.command {
164                let entry_point: String = format!("0x{:x}", entry.entryoff);
165                basic_map.insert("Entry Point".to_string(), json!(entry_point));
166            }
167        }
168        basic_map
169    }
170
171    /// Output the finalized report for the analysed executable.
172    ///
173    /// `--json <PATH>` (back-compat) always wins: it writes the pretty JSON
174    /// report to `PATH`, or to stdout when `PATH` is `-`. Otherwise the
175    /// `format` selects the stdout representation: human tables (default),
176    /// JSON, or a native SARIF 2.1.0 document.
177    pub fn output(&self, json: Option<String>, format: Format) -> BinResult<()> {
178        if let Some(path) = json {
179            let output: String = serde_json::to_string_pretty(self)?;
180            if path == "-" {
181                println!("{output}");
182            } else {
183                fs::write(path, output)?;
184            }
185            return Ok(());
186        }
187
188        match format {
189            Format::Json => {
190                println!("{}", serde_json::to_string_pretty(self)?);
191            },
192            Format::Sarif => {
193                let report: String = self.to_sarif(env!("CARGO_PKG_VERSION"))?;
194                sarif::validate_sarif(&report)?;
195                println!("{report}");
196            }
197            Format::Markdown => {
198                let report: String = self.to_sarif(env!("CARGO_PKG_VERSION"))?;
199                let markdown: String = sarif::to_markdown(&report)?;
200                println!("{markdown}");
201            },
202            Format::Table => {
203                // will always be printed
204                Detector::table("BASIC", &self.basic);
205                Detector::table("COMPILATION", &self.compilation);
206                Detector::table("EXPLOIT MITIGATIONS", &self.mitigations);
207
208                // get instrumentation if any are set
209                if !self.instrumentation.is_empty() {
210                    Detector::table("INSTRUMENTATION", &self.instrumentation);
211                }
212            },
213        }
214        Ok(())
215    }
216
217    /// Build a native SARIF 2.1.0 JSON report from this detector's findings.
218    /// The analysed binary's absolute path (from the basic section) anchors
219    /// every result's `artifactLocation`.
220    ///
221    /// # Errors
222    /// Returns an error if SARIF construction or serialization fails.
223    pub fn to_sarif(&self, tool_version: &str) -> BinResult<String> {
224        let binary_uri: &str = self
225            .basic
226            .get("Absolute Path")
227            .and_then(Value::as_str)
228            .unwrap_or_default();
229        let sections: [(&str, &GenericMap); 4] = [
230            ("basic", &self.basic),
231            ("compilation", &self.compilation),
232            ("mitigations", &self.mitigations),
233            ("instrumentation", &self.instrumentation),
234        ];
235        sarif::build(binary_uri, &sections, tool_version)
236    }
237
238    /// Auto-generate the `report.sarif` + `report.md` pair under
239    /// `output_dir`, per the pipeline in `skills/rust-sarif.md`. The SARIF is
240    /// strict-validated and the Markdown structurally validated before each
241    /// file is written.
242    ///
243    /// # Errors
244    /// Returns an error if `output_dir` is not a directory, validation fails,
245    /// or a report file cannot be written.
246    pub fn write_reports(&self, output_dir: &Path) -> BinResult<()> {
247        if !output_dir.is_dir() {
248            return Err(BinError::Internal(format!(
249                "output directory does not exist: {}",
250                output_dir.display()
251            )));
252        }
253        let sarif_json: String = self.to_sarif(env!("CARGO_PKG_VERSION"))?;
254        sarif::validate_sarif(&sarif_json)?;
255        fs::write(output_dir.join("report.sarif"), &sarif_json)?;
256
257        let markdown: String = sarif::to_markdown(&sarif_json)?;
258        fs::write(output_dir.join("report.md"), &markdown)?;
259        Ok(())
260    }
261
262    #[inline]
263    pub fn table(name: &str, mapping: &GenericMap) {
264        println!("-----------------------------------------------");
265        println!("{}", name);
266        println!("-----------------------------------------------\n");
267        for (name, feature) in mapping {
268            let value: String = match feature {
269                Value::Bool(true) => String::from("\x1b[0;32m✔️\x1b[0m"),
270                Value::Bool(false) => String::from("\x1b[0;31m✖️\x1b[0m"),
271                Value::String(val) => val.clone(),
272                other => other.to_string(),
273            };
274            println!("{0: <45} {1}", name, value);
275        }
276        println!();
277    }
278}