mod entropy;
mod ioc;
mod pe;
mod schema;
mod strings;
mod yara_scan;
use clap::{Parser, Subcommand};
use rayon::prelude::*;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{fs, io};
#[derive(Parser)]
#[command(
name = "offsetscan",
about = "Standalone native corpus-scale static-triage engine — schema-compatible with OffsetInspect.",
version
)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true)]
ndjson: bool,
#[arg(long, global = true, conflicts_with = "ndjson")]
csv: bool,
}
#[derive(Subcommand)]
enum Commands {
Pe {
path: String,
#[arg(long)]
recurse: bool,
#[arg(long, value_parser = parse_offset)]
offset: Option<u64>,
},
Entropy {
path: String,
#[arg(long, default_value_t = 256)]
window: usize,
#[arg(long, default_value_t = 7.0)]
high_threshold: f64,
#[arg(long)]
recurse: bool,
},
Strings {
path: String,
#[arg(long, default_value_t = 4)]
min_length: usize,
#[arg(long)]
recurse: bool,
},
Ioc {
path: String,
#[arg(long)]
recurse: bool,
},
}
fn parse_offset(s: &str) -> Result<u64, String> {
let s = s.trim();
match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
Some(hex) => u64::from_str_radix(hex, 16).map_err(|e| e.to_string()),
None => s.parse::<u64>().map_err(|e| e.to_string()),
}
}
fn expand_paths(path: &str, recurse: bool) -> Vec<PathBuf> {
let p = Path::new(path);
if p.is_file() {
return vec![p.to_path_buf()];
}
if p.is_dir() {
let mut out = Vec::new();
if recurse {
for entry in walkdir::WalkDir::new(p).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
out.push(entry.into_path());
}
}
} else if let Ok(read) = fs::read_dir(p) {
for entry in read.filter_map(|e| e.ok()) {
if entry.path().is_file() {
out.push(entry.path());
}
}
}
return out;
}
glob::glob(path)
.map(|paths| paths.filter_map(|p| p.ok()).collect())
.unwrap_or_default()
}
fn main() {
let cli = Cli::parse();
let ndjson = cli.ndjson;
let csv = cli.csv;
if csv && !matches!(&cli.command, Commands::Ioc { .. }) {
eprintln!(
"offsetscan: --csv is only supported for the `ioc` subcommand (the other commands produce nested data)."
);
std::process::exit(2);
}
match cli.command {
Commands::Pe {
path,
recurse,
offset,
} => {
let files = expand_paths(&path, recurse);
run(files, ndjson, move |f| {
let data = fs::read(f).ok()?;
let mut info = pe::parse_pe(&data, &f.to_string_lossy()).ok()?;
if let Some(off) = offset {
info.mapped_offset = Some(off);
info.mapped_section = pe::offset_to_section(&data, off).ok().flatten();
}
Some(info)
});
}
Commands::Entropy {
path,
window,
high_threshold,
recurse,
} => {
let files = expand_paths(&path, recurse);
run(files, ndjson, move |f| {
let data = fs::read(f).ok()?;
Some(entropy::build_entropy_result(
&data,
&f.to_string_lossy(),
window,
high_threshold,
))
});
}
Commands::Strings {
path,
min_length,
recurse,
} => {
let files = expand_paths(&path, recurse);
run(files, ndjson, move |f| {
let data = fs::read(f).ok()?;
let mut hits = strings::extract_ascii_strings(&data, min_length);
hits.extend(strings::extract_utf16le_strings(&data, min_length));
Some((f.to_string_lossy().to_string(), hits))
});
}
Commands::Ioc { path, recurse } => {
let files = expand_paths(&path, recurse);
if csv {
let results: Vec<schema::Ioc> = files
.par_iter()
.filter_map(|f| {
let data = fs::read(f).ok()?;
Some(ioc::build_ioc_panel(&data, &f.to_string_lossy()))
})
.collect();
write_csv(&results);
} else {
run(files, ndjson, move |f| {
let data = fs::read(f).ok()?;
Some(ioc::build_ioc_panel(&data, &f.to_string_lossy()))
});
}
}
}
}
fn write_csv(records: &[schema::Ioc]) {
let mut wtr = csv::Writer::from_writer(io::stdout());
for record in records {
if let Err(e) = wtr.serialize(record) {
eprintln!("failed to write CSV row: {e}");
return;
}
}
if let Err(e) = wtr.flush() {
eprintln!("failed to flush CSV: {e}");
}
}
fn run<T, F>(files: Vec<PathBuf>, ndjson: bool, process: F)
where
T: serde::Serialize + Send,
F: Fn(&Path) -> Option<T> + Sync,
{
if ndjson {
files.par_iter().for_each(|f| {
if let Some(result) = process(f) {
emit_ndjson_line(&result);
}
});
} else {
let results: Vec<T> = files.par_iter().filter_map(|f| process(f)).collect();
print_json(&results);
}
}
fn emit_ndjson_line<T: serde::Serialize>(value: &T) {
match serde_json::to_string(value) {
Ok(s) => {
let stdout = io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{s}");
}
Err(e) => eprintln!("failed to serialize output: {e}"),
}
}
fn print_json<T: serde::Serialize>(value: &T) {
match serde_json::to_string_pretty(value) {
Ok(s) => println!("{s}"),
Err(e) => eprintln!("failed to serialize output: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_offset_accepts_hex_and_decimal() {
assert_eq!(parse_offset("0x10").unwrap(), 16);
assert_eq!(parse_offset("0X1F").unwrap(), 31);
assert_eq!(parse_offset("42").unwrap(), 42);
assert!(parse_offset("not-a-number").is_err());
}
#[test]
fn ioc_csv_header_uses_offsetinspect_field_names() {
let ioc = schema::Ioc {
file: "x".into(),
file_size: 1,
md5: "m".into(),
sha1: "s".into(),
sha256: "h".into(),
overall_entropy: 0.0,
high_entropy_windows: 0,
printable_string_count: 0,
is_pe: false,
machine: None,
imp_hash: None,
imported_dll_count: None,
has_overlay: None,
overlay_size: None,
};
let mut wtr = csv::Writer::from_writer(vec![]);
wtr.serialize(&ioc).unwrap();
let out = String::from_utf8(wtr.into_inner().unwrap()).unwrap();
let header = out.lines().next().unwrap();
assert!(header.contains("MD5"));
assert!(header.contains("SHA256"));
assert!(header.contains("IsPE"));
}
}