use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicUsize;
pub(crate) static GHIDRA_DEGRADED_TO_STRINGS: AtomicUsize = AtomicUsize::new(0);
pub(crate) static BINARY_UNREADABLE: AtomicUsize = AtomicUsize::new(0);
pub fn binary_degraded_to_strings() -> usize {
GHIDRA_DEGRADED_TO_STRINGS.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn binary_unreadable() -> usize {
BINARY_UNREADABLE.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn reset_binary_counters() {
GHIDRA_DEGRADED_TO_STRINGS.store(0, std::sync::atomic::Ordering::Relaxed);
BINARY_UNREADABLE.store(0, std::sync::atomic::Ordering::Relaxed);
}
use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
use analyzers::{
BinaryAnalysisDegradation, BinaryAnalysisOutcome, BinaryAnalysisRequest, BinaryAnalyzer,
GhidraAnalyzer,
};
pub struct BinarySource {
path: PathBuf,
ghidra_path: Option<PathBuf>,
limits: crate::SourceLimits,
}
impl BinarySource {
pub fn new(path: impl Into<PathBuf>) -> Self {
let ghidra_path = analyzers::find_ghidra_headless();
Self {
path: path.into(),
ghidra_path,
limits: crate::SourceLimits::default(),
}
}
pub(crate) fn strings_only(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
ghidra_path: None,
limits: crate::SourceLimits::default(),
}
}
pub fn with_limits(mut self, limits: crate::SourceLimits) -> Self {
self.limits = limits;
self
}
fn analyzer_chunks(
&self,
analyzer: &dyn BinaryAnalyzer,
) -> Result<Vec<Result<Chunk, SourceError>>, SourceError> {
let request = BinaryAnalysisRequest {
path: &self.path,
decompiled_bytes_limit: self.limits.binary_decompiled_bytes,
timeout: crate::timeouts::GHIDRA_ANALYSIS,
};
match analyzer.analyze(request)? {
BinaryAnalysisOutcome::Complete(chunks) => {
let mut rows = chunks.into_iter().map(Ok).collect::<Vec<_>>();
rows.extend(self.strings_chunks());
Ok(rows)
}
BinaryAnalysisOutcome::Degraded(degradation) => {
self.report_analysis_degradation(°radation);
GHIDRA_DEGRADED_TO_STRINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(self.strings_chunks())
}
}
}
fn report_analysis_degradation(&self, degradation: &BinaryAnalysisDegradation) {
match degradation {
BinaryAnalysisDegradation::ToolFailure {
reason,
stderr_excerpt,
} => {
let diagnostic = if stderr_excerpt.is_empty() {
String::new()
} else {
format!("; ghidra stderr: {stderr_excerpt}")
};
eprintln!(
"keyhog: WARNING: Ghidra decompiler analysis failed for {} ({reason}{diagnostic}); \
falling back to shallow strings-only extraction, encoded/split secrets \
this binary may carry will NOT be recovered. Re-run after fixing Ghidra to \
restore deep analysis.",
self.path.display()
);
}
BinaryAnalysisDegradation::OutputTooLarge {
actual_bytes,
limit_bytes,
} => {
eprintln!(
"keyhog: WARNING: Ghidra decompiled output for {} is {} bytes (> {} cap); \
falling back to shallow strings-only extraction, encoded/split secrets may \
be missed.",
self.path.display(),
actual_bytes,
limit_bytes
);
}
}
}
fn strings_chunks(&self) -> Vec<Result<Chunk, SourceError>> {
let path_display = crate::filesystem::display_path(&self.path);
let mut chunks = Vec::new();
let bytes = match read_binary_capped(&self.path, self.limits.binary_read_bytes) {
Ok(read) => {
if read.truncated {
chunks.push(Err(report_binary_truncation(
&path_display,
self.limits.binary_read_bytes,
)));
}
read.bytes
}
Err(error) => {
eprintln!(
"keyhog: WARNING: cannot read binary {} ({error}); it was NOT scanned for secrets.",
self.path.display()
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
BINARY_UNREADABLE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return vec![Err(SourceError::Other(format!(
"failed to scan binary {}: cannot read file ({error}); it was not scanned for secrets",
self.path.display()
)))];
}
};
#[cfg(feature = "binary")]
{
if let Some(section_chunks) = sections::extract_sections(&bytes, &path_display) {
chunks.extend(section_chunks.into_iter().map(Ok));
}
}
let strings = extract_printable_strings(&bytes, crate::strings::MIN_PRINTABLE_STRING_LEN);
if !strings.is_empty() {
chunks.push(Ok(Chunk {
data: crate::strings::join_sensitive_strings(&strings, "\n"),
metadata: ChunkMetadata {
base_offset: 0,
base_line: 0,
source_type: "binary:strings".into(),
path: Some(path_display.into()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
decoded_span: None,
},
}));
}
if chunks.is_empty() {
eprintln!(
"keyhog: WARNING: binary {} yielded no scannable sections or printable strings; it was NOT scanned for secrets.",
self.path.display()
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Binary);
return vec![Err(SourceError::Other(format!(
"failed to scan binary {}: yielded no scannable sections or printable strings, so no binary bytes were scanned for secrets",
self.path.display()
)))];
}
chunks
}
}
fn report_binary_truncation(path_display: &str, cap: usize) -> SourceError {
eprintln!(
"keyhog: WARNING: binary {path_display} exceeded the {cap} byte strings-read cap; only the first {cap} bytes were scanned."
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::SourceTruncated);
SourceError::Other(format!(
"binary {path_display} exceeded the {cap}-byte strings-read cap; remaining binary bytes were not scanned"
))
}
impl Source for BinarySource {
fn name(&self) -> &str {
"binary"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
crate::gate_scan(|| {
let rows = if let Some(ghidra_bin) = &self.ghidra_path {
let analyzer = GhidraAnalyzer::new(ghidra_bin);
match self.analyzer_chunks(&analyzer) {
Ok(rows) => rows,
Err(e) => vec![Err(e)],
}
} else {
self.strings_chunks()
};
Box::new(rows.into_iter())
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
struct CappedBinaryRead {
bytes: Vec<u8>,
truncated: bool,
}
fn read_binary_capped(path: &Path, cap: usize) -> std::io::Result<CappedBinaryRead> {
let file = crate::filesystem::open_file_safe(path)?;
let capacity_hint = file.metadata()?.len();
let cap = u64::try_from(cap).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"binary read cap is too large for this platform",
)
})?;
let read = crate::capped_read::read_to_cap(file, cap, Some(capacity_hint))?;
Ok(CappedBinaryRead {
bytes: read.bytes,
truncated: read.truncated,
})
}
pub(crate) fn extract_printable_strings(
bytes: &[u8],
min_len: usize,
) -> Vec<keyhog_core::SensitiveString> {
crate::strings::extract_printable_strings(bytes, min_len)
}
mod analyzers;
pub(crate) mod literals;
#[cfg(feature = "binary")]
pub(crate) mod sections;
#[cfg(test)]
mod tests;