#![allow(missing_docs)]
use std::ffi::OsStr;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::hyperscan_cache::{HYPERSCAN_CACHE_PREFIX, HYPERSCAN_CACHE_SUFFIX};
#[derive(Debug, Default, Clone)]
pub struct HardeningReport {
pub no_core_dumps: bool,
pub no_ptrace: bool,
pub mlocked: bool,
pub coredump_filter_safe: bool,
pub failures: Vec<String>,
}
#[must_use]
pub fn apply_protections(lockdown: bool) -> HardeningReport {
apply_protections_with_persistence_paths(lockdown, std::iter::empty::<PathBuf>())
}
#[must_use]
pub fn apply_protections_with_persistence_paths<I, P>(
lockdown: bool,
persistence_paths: I,
) -> HardeningReport
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
if !lockdown {
return apply_default_protections();
}
let mut report = apply_lockdown_protections();
for path in lockdown_disk_cache_violations_for_paths(persistence_paths) {
report.failures.push(format!(
"lockdown disk cache exists at {} and could expose past findings. \
Fix: remove it and rerun.",
path.display()
));
}
report
}
fn apply_default_protections() -> HardeningReport {
let mut report = HardeningReport::default();
#[cfg(target_os = "linux")]
{
let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
if rc == 0 {
report.no_core_dumps = true;
report.no_ptrace = true;
} else {
let err = std::io::Error::last_os_error();
report
.failures
.push(format!("prctl(PR_SET_DUMPABLE): {err}"));
}
}
#[cfg(target_os = "macos")]
{
const PT_DENY_ATTACH: libc::c_int = 31;
let rc = unsafe { libc::ptrace(PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) };
if rc == 0 {
report.no_ptrace = true;
report.no_core_dumps = true;
} else {
let err = std::io::Error::last_os_error();
report
.failures
.push(format!("ptrace(PT_DENY_ATTACH): {err}"));
}
}
#[cfg(target_os = "windows")]
{
report.failures.push(
"process mitigation policy not applied on Windows \
(SetProcessMitigationPolicy unwired); WER may still write a crash dump"
.to_string(),
);
}
report
}
fn apply_lockdown_protections() -> HardeningReport {
let mut report = apply_default_protections();
#[cfg(target_os = "linux")]
{
let rc = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
if rc == 0 {
report.mlocked = true;
} else {
let err = std::io::Error::last_os_error();
report.failures.push(format!("mlockall: {err}"));
}
let rlim_zero = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
let rc = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim_zero) };
if rc != 0 {
let err = std::io::Error::last_os_error();
report
.failures
.push(format!("setrlimit(RLIMIT_CORE, 0): {err}"));
}
let filter = std::fs::read_to_string("/proc/self/coredump_filter")
.ok() .and_then(|s| u32::from_str_radix(s.trim(), 16).ok()); let rlimit_blocked = rc == 0;
match filter {
Some(0) => report.coredump_filter_safe = true,
Some(_other) if rlimit_blocked => {
report.coredump_filter_safe = true;
}
Some(other) => report.failures.push(format!(
"/proc/self/coredump_filter = 0x{other:x} - anonymous pages would be dumped; \
RLIMIT_CORE could not be set to 0 either. Set ulimit -c 0 in the parent shell."
)),
None => {
if rlimit_blocked {
report.coredump_filter_safe = true;
} else {
report
.failures
.push("could not read /proc/self/coredump_filter".into());
}
}
}
}
#[cfg(not(target_os = "linux"))]
{
report.mlocked = false;
report.failures.push(format!(
"memory locking (mlockall) is unavailable on {}; lockdown cannot \
keep credentials out of swap on this platform",
std::env::consts::OS
));
}
report
}
#[must_use]
pub(crate) fn lockdown_disk_cache_violations() -> Vec<PathBuf> {
lockdown_disk_cache_violations_for_paths(std::iter::empty::<PathBuf>())
}
#[must_use]
pub(crate) fn lockdown_disk_cache_violations_for_paths<I, P>(persistence_paths: I) -> Vec<PathBuf>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut hits = Vec::new();
if let Some(keyhog_root) = crate::keyhog_cache_root() {
let has_findings_cache = match std::fs::read_dir(&keyhog_root) {
Ok(entries) => keyhog_cache_contains_findings(&keyhog_root, entries),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
eprintln!(
"keyhog: cannot inspect cache dir '{}' for past-findings artifacts: {e}; \
refusing lockdown (fail-closed)",
keyhog_root.display()
);
true
}
};
if has_findings_cache {
hits.push(keyhog_root);
}
}
for path in persistence_paths {
let path = path.as_ref();
if explicit_persistence_path_is_violation(path) {
push_unique_path(&mut hits, path.to_path_buf());
}
}
hits
}
fn explicit_persistence_path_is_violation(path: &Path) -> bool {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) {
Ok(entries) => keyhog_cache_contains_findings(path, entries),
Err(error) => {
eprintln!(
"keyhog: cannot inspect configured cache dir '{}' for past-findings \
artifacts: {error}; refusing lockdown (fail-closed)",
path.display()
);
true
}
},
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => {
eprintln!(
"keyhog: cannot inspect configured cache path '{}' for past-findings artifacts: \
{error}; refusing lockdown (fail-closed)",
path.display()
);
true
}
}
}
fn push_unique_path(hits: &mut Vec<PathBuf>, path: PathBuf) {
if !hits.iter().any(|hit| hit == &path) {
hits.push(path);
}
}
fn keyhog_cache_contains_findings<I>(keyhog_root: &Path, entries: I) -> bool
where
I: IntoIterator<Item = std::io::Result<std::fs::DirEntry>>,
{
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
eprintln!(
"keyhog: cannot inspect an entry in cache dir '{}' for past-findings \
artifacts: {error}; refusing lockdown (fail-closed)",
keyhog_root.display()
);
return true;
}
};
match trusted_compiled_pattern_cache_entry(&entry) {
Ok(true) => {}
Ok(false) => return true,
Err(error) => {
eprintln!(
"keyhog: cannot inspect candidate compiled-pattern cache entry '{}' in '{}' \
for past-findings artifacts: {error}; refusing lockdown (fail-closed)",
entry.file_name().to_string_lossy(),
keyhog_root.display()
);
return true;
}
}
}
false
}
fn trusted_compiled_pattern_cache_entry(entry: &std::fs::DirEntry) -> std::io::Result<bool> {
if !compiled_pattern_cache_filename(&entry.file_name()) {
return Ok(false);
}
if !entry.file_type()?.is_file() {
return Ok(false);
}
compiled_pattern_cache_header_is_valid(&entry.path())
}
fn compiled_pattern_cache_filename(name: &OsStr) -> bool {
let Some(name) = name.to_str() else {
return false;
};
let Some(digest) = name
.strip_prefix(HYPERSCAN_CACHE_PREFIX)
.and_then(|s| s.strip_suffix(HYPERSCAN_CACHE_SUFFIX))
else {
return false;
};
digest.len() == crate::git_lfs::SHA256_HEX_LEN
&& digest
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
fn compiled_pattern_cache_header_is_valid(path: &Path) -> std::io::Result<bool> {
let mut file = std::fs::File::open(path)?;
let mut header = [0_u8; crate::HYPERSCAN_CACHE_HEADER_LEN];
match file.read_exact(&mut header) {
Ok(()) => Ok(crate::hyperscan_cache_header_is_valid(&header)),
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
Err(error) => Err(error),
}
}
pub(crate) fn lockdown_cache_entry_error_is_violation_for_test() -> bool {
let entries = std::iter::once(Err::<std::fs::DirEntry, std::io::Error>(
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "entry denied"),
));
keyhog_cache_contains_findings(Path::new("<test-cache>"), entries)
}