Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 32 additions & 49 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,41 @@ on:
permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check

clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2
- run: cargo clippy --workspace --lib --bins -- -D warnings

test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2
- run: cargo test --workspace
# fmt · clippy · test · MSRV · cargo-deny · cargo-vet · secret scan ·
# fuzz build-check · rustdoc · coverage · path-dep gate.
ci:
uses: SecurityRonin/fleet-ci/.github/workflows/rust-ci.yml@619094ad54edc586f5c2733358e00326b30790bd
with:
# CARRIED ACROSS. The replaced test job ran on [ubuntu-latest, macos-latest]
# — not windows, and not ubuntu alone. Until `test-os` existed this repo
# could not adopt at all: `os-matrix: true` adds a platform it does not
# support, `false` drops one it does. Either is a behaviour change smuggled
# into an adoption PR, so the platform set is named explicitly and is
# identical to before.
test-os: '["ubuntu-latest","macos-latest"]'

msrv:
name: MSRV (1.75)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.75"
- uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2
- run: cargo check --workspace
# MIGRATION DEBT — and the first coverage this repo has ever measured, as
# the replaced workflow had no coverage job at all. There is no earlier
# number to regress from. Measured at the fleet scope
# (--workspace --all-features): 92.76% of 80,212 lines.
#
# The floor is 92, just under the measured value, so it holds the line
# rather than granting slack. It is NOT the fleet's per-line gate: a floor
# cannot honour a `// cov:unreachable` exemption and never names which
# lines are uncovered, so the shared workflow renders it as debt and warns
# on every run.
#
# REMOVE WHEN: the uncovered surface is tested (vol_compat.rs, at 76.81% of
# lines, is the largest single gap) and this repo can inherit
# `coverage-gate: strict`.
coverage-gate: floor
coverage-floor: 92

# Proves the README "single static binary" claim continuously: build the mem4n6
# binary for musl and fail if it is not actually statically linked. The release
# workflow ships these musl artifacts (release.yml).
# Repo-specific: mem4n6 ships as a static musl binary, and "it built" is not
# the same claim as "it has no external dependencies". Carried over verbatim,
# including the toolchain pin — a floating `stable` adds the musl target to the
# wrong toolchain and the pinned build then fails with E0463.
musl-static:
name: Static musl binary
runs-on: ubuntu-latest
Expand Down
38 changes: 38 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
title = "memory-forensic gitleaks config"

[extend]
useDefault = true

# This crate HUNTS credentials in memory images, so it necessarily contains
# examples of what credentials look like: the PEM header markers it searches
# for, the vendor key prefixes it pattern-matches, and synthetic tokens in
# `#[cfg(test)]` assertions that prove each detector fires. gitleaks reports the
# detector's own machinery as the thing it detects.
#
# Everything below is a placeholder or a public constant. None opens anything,
# none authenticates to anything, and no rotation is warranted:
#
# -----BEGIN … PRIVATE KEY----- PEM header markers in ssh_agent_keys.rs's
# PEM_SIGS table and regex_classifier.rs —
# the literal strings being searched FOR.
# sk_live_ABCDEF… placeholder Stripe key; the body is a
# letter sequence, not a issued key.
# xoxb-123456789012-… placeholder Slack token; counted ramp.
# AIza… Google API-key PREFIX used as a matcher.
# eyJhbGciOiJSUz… JWT header, base64 of {"alg":"RS…} — the
# format marker, not a signed token.
# 31d6cfe0d16ae931b73c59d7e0c089c0 the well-known NTLM hash of the EMPTY
# string; a published constant.
#
# Scoped to these literals, NOT to the rules and NOT to the files: a real
# credential appearing in any of these files still fails the job.
[allowlist]
description = "Detector patterns and synthetic test tokens — not credentials"
regexes = [
'''-----BEGIN (OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----''',
'''sk_live_ABCDEF[A-Za-z0-9]*''',
'''xoxb-123456789012-[0-9A-Za-z-]*''',
'''AIza[A-Za-z0-9_\-]{35}''',
'''eyJhbGciOiJSUz[A-Za-z0-9_\-\.]*''',
'''31d6cfe0d16ae931b73c59d7e0c089c0''',
# Firefox logins.json fixtures in #[cfg(test)]: four repeated letters then
# the sequential filler 1234567890abcdef. Not a Firefox-encrypted blob.
'''^[A-Z]{4}1234567890abcdef==$''',
]
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ members = [

[workspace.package]
edition = "2021"
rust-version = "1.75"
rust-version = "1.87"
license = "Apache-2.0"
repository = "https://github.com/SecurityRonin/memory-forensic"

Expand Down Expand Up @@ -130,7 +130,9 @@ name = "mem4n6"
version = "0.2.0"
description = "Memory forensics CLI — self-profiling Windows/Linux kernel walking from any dump format, cross-checked against Volatility 3"
edition.workspace = true
rust-version.workspace = true
# A binary, so its floor IS the pinned toolchain (rust-toolchain.toml):
# nothing pins a library dependency against a *4n6 CLI.
rust-version = "1.96.0"
license.workspace = true
repository.workspace = true
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-carve/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "memf-carve"
version = "0.1.3"
description = "Plane-V memory artifact carving: per-process virtual-address-space carving over the forensic-carve sweep engine (memory medium of the fleet carving contract)"
edition.workspace = true
rust-version.workspace = true
rust-version = "1.88"
license.workspace = true
repository.workspace = true

Expand Down
4 changes: 2 additions & 2 deletions crates/memf-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
//! Virtual address translation and kernel object reading.
//!
//! This crate provides:
//! - [`VirtualAddressSpace`] — page table walking for x86_64 (4-level, 5-level),
//! - `VirtualAddressSpace` — page table walking for x86_64 (4-level, 5-level),
//! AArch64, and x86 PAE/non-PAE modes
//! - [`ObjectReader`] — high-level kernel struct traversal using symbol information
//! - `ObjectReader` — high-level kernel struct traversal using symbol information

pub mod lzo;
// Folded in from the former memf-framebuffer crate (cross-OS framebuffer extraction).
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-correlate/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "memf-correlate"
version = "0.3.0"
description = "Forensic event correlation model for the memf forensics framework"
edition.workspace = true
rust-version.workspace = true
rust-version = "1.75"
license.workspace = true

[dependencies]
Expand Down
5 changes: 3 additions & 2 deletions crates/memf-correlate/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! Forensic event correlation model for the memf forensics framework.
//!
//! Provides the [`ForensicEvent`] data model, severity classification,
//! MITRE ATT&CK mapping, and the [`IntoForensicEvents`] conversion trait.
//! Provides the [`ForensicEvent`](event::ForensicEvent) data model, severity
//! classification, MITRE ATT&CK mapping, and the
//! [`IntoForensicEvents`](traits::IntoForensicEvents) conversion trait.

#![warn(missing_docs)]
#![deny(unsafe_code)]
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-format/src/test_builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ impl CrashDumpBuilder {
/// `data.len()` must be a multiple of 4096.
pub fn add_run(mut self, base_page: u64, data: &[u8]) -> Self {
assert!(
data.len() % 4096 == 0,
data.len().is_multiple_of(4096),
"run data length must be a multiple of 4096"
);
self.runs.push((base_page, data.to_vec()));
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-linux/src/check_fops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pub fn is_kernel_text_address(addr: u64, kernel_start: u64, kernel_end: u64) ->

/// Read function pointers from a `file_operations` struct and classify each.
///
/// For each known field in [`FOP_FIELDS`], reads the pointer value. Non-null
/// For each known field in `FOP_FIELDS`, reads the pointer value. Non-null
/// pointers are checked against the kernel text range.
pub fn check_fops_entry<P: PhysicalMemoryProvider>(
reader: &ObjectReader<P>,
Expand Down
6 changes: 3 additions & 3 deletions crates/memf-linux/src/check_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const FUNCTIONS_TO_CHECK: &[&str] = &[

/// Check key kernel functions for inline hooks.
///
/// Reads the first [`PROLOGUE_SIZE`] bytes of each function in
/// [`FUNCTIONS_TO_CHECK`] and looks for JMP/CALL trampoline patterns.
/// Reads the first `PROLOGUE_SIZE` bytes of each function in
/// `FUNCTIONS_TO_CHECK` and looks for JMP/CALL trampoline patterns.
pub fn check_inline_hooks<P: PhysicalMemoryProvider>(
reader: &ObjectReader<P>,
) -> Result<Vec<KernelHookInfo>> {
Expand Down Expand Up @@ -64,7 +64,7 @@ pub fn check_inline_hooks<P: PhysicalMemoryProvider>(
let (hook_type, target) = analyze_prologue(&prologue, func_addr);
// Suspicious only when a hook IS present AND the target is outside kernel text.
// A jmp into a legitimate kernel function is not suspicious.
let suspicious = hook_type != "none" && target.map_or(true, |t| t < stext || t > etext);
let suspicious = hook_type != "none" && target.is_none_or(|t| t < stext || t > etext);

results.push(KernelHookInfo {
symbol: func_name.to_string(),
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-linux/src/elfinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const ELF64_HEADER_SIZE: usize = 64;
/// Walk all process VMAs and extract ELF headers.
///
/// For each process, walks the VMA list and reads the first
/// [`ELF64_HEADER_SIZE`] bytes from each region. Regions starting
/// `ELF64_HEADER_SIZE` bytes from each region. Regions starting
/// with the ELF magic are parsed and returned.
pub fn walk_elfinfo<P: PhysicalMemoryProvider>(reader: &ObjectReader<P>) -> Result<Vec<ElfInfo>> {
let init_task_addr = reader
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-linux/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub enum Error {

/// Walker-specific error.
///
/// Prefer [`WalkFailed`] for new code.
/// Prefer `WalkFailed` for new code.
#[error("walker error: {0}")]
Walker(String),

Expand Down
2 changes: 1 addition & 1 deletion crates/memf-linux/src/magic_gid.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Magic GID detection — identifies processes controlled by LD_PRELOAD rootkits.
//!
//! Father rootkit (github.com/mav8557/Father) grants GID 7823 to processes
//! it controls via its accept() hook. Scanning /proc/<pid>/status for
//! it controls via its accept() hook. Scanning `/proc/<pid>/status` for
//! supplementary GIDs that match known rootkit magic values is a reliable
//! indicator even when the process is hidden from readdir.

Expand Down
4 changes: 2 additions & 2 deletions crates/memf-linux/src/psxview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub fn walk_psxview<P: PhysicalMemoryProvider>(
if let Ok(info) = read_task_info(reader, init_task_addr) {
let in_pid_hash = pid_hash_pids
.as_ref()
.map_or(true, |set| set.contains(&info.0));
.is_none_or(|set| set.contains(&info.0));
results.push(PsxViewInfo {
pid: info.0,
comm: info.1,
Expand All @@ -56,7 +56,7 @@ pub fn walk_psxview<P: PhysicalMemoryProvider>(
if let Ok(info) = read_task_info(reader, task_addr) {
let in_pid_hash = pid_hash_pids
.as_ref()
.map_or(true, |set| set.contains(&info.0));
.is_none_or(|set| set.contains(&info.0));
results.push(PsxViewInfo {
pid: info.0,
comm: info.1,
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-strings/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "memf-strings"
version = "0.2.1"
description = "String extraction, classification, and YARA-X scanning for memory forensics"
edition.workspace = true
rust-version.workspace = true
rust-version = "1.88"
license.workspace = true

[dependencies]
Expand Down
6 changes: 3 additions & 3 deletions crates/memf-symbols/src/kernel_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ fn is_kernel_pdb_name(name: &str) -> bool {
/// candidate base through the page tables, until it finds an AMD64 PE whose
/// CodeView RSDS record identifies it as an ntoskrnl variant.
///
/// Returns [`Error::NotFound`] if no kernel PE is located within the search
/// Returns [`Error::NotFound`](crate::Error::NotFound) if no kernel PE is located
/// window.
pub fn scan_for_kernel_via_dtb<P: PhysicalMemoryProvider + ?Sized>(
mem: &P,
Expand Down Expand Up @@ -496,13 +496,13 @@ fn dtb_maps_kernel_space<P: PhysicalMemoryProvider + ?Sized>(mem: &P, cr3: u64)
/// descent on. This entry point recovers the kernel DTB directly from raw
/// physical memory:
///
/// 1. Enumerate self-referencing PML4 candidates ([`enumerate_self_ref_pml4s`]).
/// 1. Enumerate self-referencing PML4 candidates (`enumerate_self_ref_pml4s`, private).
/// On a real dump this surfaces the kernel DTB *and* many process DTBs — all
/// self-reference at the same canonical index (220 on SecurityNik), so the
/// set is ambiguous.
/// 2. Order candidates by ascending physical address and accept the first whose
/// page tables map an ntkrnlmp/ntoskrnl PE with a valid RSDS GUID
/// ([`locate_kernel_via_dtb_only`]). Verification rejects self-referencing
/// (`locate_kernel_via_dtb_only`). Verification rejects self-referencing
/// pages that are not page-table roots; the lowest-physical ordering selects
/// the kernel DTB among the process DTBs (whose shared kernel half also maps
/// the kernel, so verification alone would not distinguish them).
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-symbols/src/pe_debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const MAX_PDB_NAME_LEN: usize = 256;
/// follow. The first record with a non-empty, valid-UTF-8 filename wins.
///
/// All reads are bounds-checked; malformed or truncated input yields
/// [`Error::NotFound`] rather than a panic (Paranoid Gatekeeper).
/// `Error::NotFound` rather than a panic (Paranoid Gatekeeper).
pub fn extract_pdb_id_tolerant(bytes: &[u8]) -> crate::Result<PdbId> {
extract_pdb_id_tolerant_where(bytes, |_| true)
}
Expand Down
4 changes: 2 additions & 2 deletions crates/memf-symbols/src/symserver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ fn volatility_cache_path_from(
/// Return the shared symbol cache directory — Volatility3's `CACHE_PATH`.
///
/// memf deliberately shares Volatility's store (not a memf-private dir) so a
/// single download serves both tools. See [`volatility_cache_path_from`].
/// single download serves both tools. See `volatility_cache_path_from`.
pub fn default_cache_dir() -> Option<PathBuf> {
let xdg = std::env::var("XDG_CACHE_HOME").ok();
let home = std::env::var("HOME").ok();
Expand Down Expand Up @@ -136,7 +136,7 @@ fn resolve_cache_dir_from(
}

/// Resolve the symbol cache dir from the environment, falling back to
/// [`default_cache_dir`]. See [`resolve_cache_dir_from`] for the order.
/// [`default_cache_dir`]. See `resolve_cache_dir_from` for the order.
pub fn resolve_cache_dir() -> Option<PathBuf> {
let memf = std::env::var("MEMF_SYMBOL_CACHE").ok();
let ntsp = std::env::var("_NT_SYMBOL_PATH").ok();
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-windows/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "memf-windows"
version = "0.4.2"
description = "Windows kernel memory forensic walkers (processes, threads, drivers, DLLs)"
edition.workspace = true
rust-version.workspace = true
rust-version = "1.88"
license.workspace = true

[dependencies]
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-windows/src/cachedump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ fn decrypt_dcc2(enc_data: &[u8], nlkm: &[u8], ch: &[u8]) -> Vec<u8> {
return Vec::new();
}
let mut padded = enc_data.to_vec();
while padded.len() % 16 != 0 {
while !padded.len().is_multiple_of(16) {
padded.push(0);
}
crate::hashdump::aes128_cbc_decrypt(&nlkm[16..32], &ch[..16], &padded)
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-windows/src/credman.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ fn read_unicode_string_raw<P: PhysicalMemoryProvider>(
/// Decode raw bytes as UTF-16LE if every code unit is a valid Unicode scalar.
/// Returns `Some(String)` for plaintext, `None` for encrypted/binary data.
fn decode_utf16le_or_none(bytes: &[u8]) -> Option<String> {
if bytes.len() < 2 || bytes.len() % 2 != 0 {
if bytes.len() < 2 || !bytes.len().is_multiple_of(2) {
return None;
}
let units: Vec<u16> = bytes
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-windows/src/dpapi/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ mod tests {
let mut buf = plaintext.to_vec();
// pad to 16-byte boundary
let pad_len = 16 - (buf.len() % 16);
buf.extend(std::iter::repeat(pad_len as u8).take(pad_len));
buf.extend(std::iter::repeat_n(pad_len as u8, pad_len));
enc.encrypt_padded_mut::<Pkcs7>(&mut buf, plaintext.len())
.unwrap()
.to_vec()
Expand Down
2 changes: 1 addition & 1 deletion crates/memf-windows/src/hashdump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ pub(crate) fn username_from_v(v_data: &[u8]) -> Option<String> {
/// revision-3 hbootkey and revision-2 per-user hash blobs. Returns an empty
/// `Vec` on a key/IV/length mismatch rather than panicking.
pub(crate) fn aes128_cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
if key.len() != 16 || iv.len() < 16 || data.is_empty() || data.len() % 16 != 0 {
if key.len() != 16 || iv.len() < 16 || data.is_empty() || !data.len().is_multiple_of(16) {
return Vec::new();
}
let Ok(dec) = CbcDecryptor::<Aes128>::new_from_slices(key, &iv[..16]) else {
Expand Down
Loading
Loading