crates/nornir-introspect/src/hardening.rs
//! Binary **hardening** analysis — a pure-Rust `checksec`: RELRO / PIE /
//! stack-canary / NX / stripped / debug-info-present / FORTIFY, plus the
//! format/arch/class/size header, all derived from the ELF header, program
//! headers, dynamic table, and symbol table via the `object` reader (no
//! shell-out to `checksec`/`readelf`). Non-ELF inputs still report
//! format/arch/class/size; the ELF-specific hardening flags default off.
use std::fs::File;
use std::path::Path;
use anyhow::{Context, Result};
use memmap2::Mmap;
use object::read::elf::{ElfFile, ElfFile32, ElfFile64, FileHeader, ProgramHeader};
use object::{elf, Endianness, FileKind, Object, ObjectSection, ObjectSymbol, ReadRef, SymbolKind};
use serde::{Deserialize, Serialize};
/// The checksec-class hardening + format/size header for one artifact — one row
/// of `binary_facts`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Hardening {
/// Container format: `elf` / `macho` / `pe` / `wasm` / `coff` / `unknown`.
pub format: String,
/// CPU architecture label, e.g. `X86_64`, `Aarch64`.
pub arch: String,
/// Object class: `exe` / `dylib` / `relocatable` / `core` / `other`.
pub class: String,
/// Artifact size on disk, bytes.
pub total_bytes: u64,
/// No static symbol table (`.symtab`) — the binary was stripped.
pub stripped: bool,
/// Carries DWARF debug sections (`.debug_*`).
pub has_debug_info: bool,
/// Position-independent executable (ET_DYN / `DF_1_PIE`).
pub pie: bool,
/// RELRO level: `none` / `partial` / `full`.
pub relro: String,
/// Non-executable stack (`PT_GNU_STACK` without `PF_X`).
pub nx: bool,
/// Stack-smashing protector present (`__stack_chk_fail`/`__stack_chk_guard`).
pub stack_canary: bool,
/// `_FORTIFY_SOURCE` in use (a `*_chk` fortified libc symbol is referenced).
pub fortify: bool,
/// A legacy `DT_RPATH` entry is present.
pub rpath: bool,
/// A `DT_RUNPATH` entry is present.
pub runpath: bool,
/// The GNU build-id note (`.note.gnu.build-id`), hex, if present.
pub build_id: Option<String>,
/// Number of sections.
pub n_sections: u64,
/// Defined (non-undefined) code/data symbols.
pub n_sym_defined: u64,
/// Undefined (imported) code/data symbols.
pub n_sym_undefined: u64,
/// `DT_NEEDED` dynamic dependency count.
pub n_dyn_deps: u64,
}
/// Analyse the hardening posture + header of the artifact at `path`.
pub fn analyze_hardening(path: &Path) -> Result<Hardening> {
let file = File::open(path).with_context(|| format!("open artifact {}", path.display()))?;
let mmap = unsafe { Mmap::map(&file) }.with_context(|| format!("mmap {}", path.display()))?;
let total_bytes = mmap.len() as u64;
let obj =
object::File::parse(&*mmap).with_context(|| format!("parse object {}", path.display()))?;
let format = match obj.format() {
object::BinaryFormat::Elf => "elf",
object::BinaryFormat::MachO => "macho",
object::BinaryFormat::Pe => "pe",
object::BinaryFormat::Wasm => "wasm",
object::BinaryFormat::Coff => "coff",
_ => "unknown",
}
.to_string();
let arch = format!("{:?}", obj.architecture());
let class = match obj.kind() {
object::ObjectKind::Executable => "exe",
object::ObjectKind::Dynamic => "dylib",
object::ObjectKind::Relocatable => "relocatable",
object::ObjectKind::Core => "core",
_ => "other",
}
.to_string();
// Format-agnostic facts from the generic reader.
let n_sections = obj.sections().count() as u64;
let has_debug_info = obj.sections().any(|s| {
s.name()
.map(|n| n.starts_with(".debug") || n.starts_with("__debug"))
.unwrap_or(false)
});
let stripped = obj.symbols().next().is_none() && obj.section_by_name(".symtab").is_none();
let build_id = obj
.build_id()
.ok()
.flatten()
.filter(|b| !b.is_empty())
.map(hex_encode);
// Symbol-name scan (static + dynamic) for the canary / FORTIFY tells and the
// defined/undefined counts.
let mut stack_canary = false;
let mut fortify = false;
let mut n_sym_defined = 0u64;
let mut n_sym_undefined = 0u64;
for sym in obj.symbols().chain(obj.dynamic_symbols()) {
let Ok(name) = sym.name() else { continue };
if name.is_empty() {
continue;
}
if name == "__stack_chk_fail" || name == "__stack_chk_guard" {
stack_canary = true;
}
if name.ends_with("_chk") {
fortify = true;
}
if matches!(
sym.kind(),
SymbolKind::Text | SymbolKind::Data | SymbolKind::Unknown
) {
if sym.is_undefined() {
n_sym_undefined += 1;
} else {
n_sym_defined += 1;
}
}
}
// ELF-specific hardening: PIE / RELRO / NX / RPATH / RUNPATH / dep count.
let mut elf = ElfHardening::default();
if let Ok(kind) = FileKind::parse(&*mmap) {
match kind {
FileKind::Elf64 => {
if let Ok(f) = ElfFile64::<Endianness>::parse(&*mmap) {
elf = harden_elf(&f);
}
}
FileKind::Elf32 => {
if let Ok(f) = ElfFile32::<Endianness>::parse(&*mmap) {
elf = harden_elf(&f);
}
}
_ => {}
}
}
Ok(Hardening {
format,
arch,
class,
total_bytes,
stripped,
has_debug_info,
pie: elf.pie,
relro: elf.relro,
nx: elf.nx,
stack_canary,
fortify,
rpath: elf.rpath,
runpath: elf.runpath,
build_id,
n_sections,
n_sym_defined,
n_sym_undefined,
n_dyn_deps: elf.n_dyn_deps,
})
}
/// The ELF-only slice of the hardening verdict.
struct ElfHardening {
pie: bool,
relro: String,
nx: bool,
rpath: bool,
runpath: bool,
n_dyn_deps: u64,
}
impl Default for ElfHardening {
fn default() -> Self {
ElfHardening {
pie: false,
relro: "none".to_string(),
nx: false,
rpath: false,
runpath: false,
n_dyn_deps: 0,
}
}
}
/// Class/endian-agnostic ELF hardening read: program headers for NX + the RELRO
/// segment, the dynamic table for BIND_NOW / PIE flags / RPATH / RUNPATH /
/// DT_NEEDED, the header for the ET_DYN (PIE) shape.
fn harden_elf<'data, Elf, R>(elf: &ElfFile<'data, Elf, R>) -> ElfHardening
where
Elf: FileHeader<Endian = Endianness>,
R: ReadRef<'data>,
{
let endian = elf.endian();
let e_type = elf.elf_header().e_type(endian);
// ── Program headers: NX (GNU_STACK ∌ PF_X) + the RELRO segment ──
let mut nx = false;
let mut relro_seg = false;
for ph in elf.elf_program_headers() {
match ph.p_type(endian) {
elf::PT_GNU_STACK => nx = !ph.p_flags(endian).intersects(elf::PF_X),
elf::PT_GNU_RELRO => relro_seg = true,
_ => {}
}
}
// ── Dynamic table: BIND_NOW / PIE flag / RPATH / RUNPATH / DT_NEEDED ──
let mut bind_now = false;
let mut df_1_pie = false;
let mut rpath = false;
let mut runpath = false;
let mut n_dyn_deps = 0u64;
if let Ok(dyn_table) = elf.elf_dynamic_table() {
for d in dyn_table.iter() {
match d.tag {
elf::DT_NEEDED => n_dyn_deps += 1,
elf::DT_BIND_NOW => bind_now = true,
elf::DT_FLAGS => {
if elf::DynamicFlags(d.val).intersects(elf::DF_BIND_NOW) {
bind_now = true;
}
}
elf::DT_FLAGS_1 => {
let v = elf::DynamicFlags1(d.val);
if v.intersects(elf::DF_1_NOW) {
bind_now = true;
}
if v.intersects(elf::DF_1_PIE) {
df_1_pie = true;
}
}
elf::DT_RPATH => rpath = true,
elf::DT_RUNPATH => runpath = true,
_ => {}
}
}
}
let relro = if relro_seg {
if bind_now {
"full"
} else {
"partial"
}
} else {
"none"
}
.to_string();
// A PIE executable is ET_DYN; `DF_1_PIE` confirms it against a plain DSO.
let pie = e_type == elf::ET_DYN && (df_1_pie || elf.entry() != 0);
ElfHardening {
pie,
relro,
nx,
rpath,
runpath,
n_dyn_deps,
}
}
/// Lowercase hex (no external hex dep in this leaf crate).
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
s.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
s
}
#[cfg(test)]
mod tests {
use super::*;
/// The test executable decodes to a coherent hardening verdict: an ELF (on
/// Linux) executable with a valid RELRO level and a decoded arch/class. Red
/// if the header/program-header/dynamic decode regresses.
#[test]
fn analyze_hardening_selfexe() {
let bin = std::env::current_exe().expect("current test exe");
let h = analyze_hardening(&bin).expect("analyze");
assert!(h.total_bytes > 0, "total bytes");
assert!(h.n_sections > 0, "sections");
assert!(!h.arch.is_empty(), "arch decoded");
assert!(
matches!(h.relro.as_str(), "none" | "partial" | "full"),
"relro must be a valid level, got {}",
h.relro
);
// A freshly-built test binary is not stripped and carries debug info.
assert!(!h.stripped, "test exe should not be stripped");
assert!(h.has_debug_info, "test exe should carry .debug_* sections");
}
}