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
120 changes: 120 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
RUSTFLAGS: -Dwarnings

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

clippy:
name: Clippy (-D warnings)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- run: cargo clippy --all-targets --all-features -- -D warnings

test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- run: cargo test --all-features

check-msrv:
name: MSRV (1.85.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: 1.85.0
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- run: cargo +1.85.0 build --all-features --locked

coverage:
name: Coverage (100% production lines)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6
with:
tool: cargo-llvm-cov
- name: Fail on any uncovered production line (DA:n,0), honoring cov:unreachable
run: |
cargo llvm-cov --all-features --lcov --output-path cov.lcov
python3 - <<'PY'
import sys
def marked(path, n):
try:
return "cov:unreachable" in open(path).read().splitlines()[n - 1]
except Exception:
return False
cur, bad = None, []
for line in open("cov.lcov").read().splitlines():
if line.startswith("SF:"):
cur = line[3:]
elif line.startswith("DA:") and cur:
if "/tests/" in cur or "/fuzz/" in cur:
continue
n, hits = line[3:].split(",")
if hits == "0" and not marked(cur, int(n)):
bad.append(f"{cur}:{n}")
if bad:
print("Uncovered production lines — add a test or // cov:unreachable:")
for b in bad:
print(" " + b)
sys.exit(1)
print("All production lines covered.")
PY

deny:
name: cargo-deny (bans/licenses/sources)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check bans licenses sources

advisories:
name: cargo-deny (advisories)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check advisories
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 24 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ members = [

[workspace.package]
edition = "2021"
rust-version = "1.80"
# True MSRV floor: goblin 0.10 → scroll_derive 0.13.x is edition-2024 (needs 1.85);
# pulled transitively by exec-pe-core. 1.84 fails to build; 1.85 is the honest floor.
rust-version = "1.85"
license = "Apache-2.0"
authors = ["Albert Hui <albert@securityronin.com>"]
repository = "https://github.com/SecurityRonin/exec-pe-forensic"
Expand Down Expand Up @@ -41,6 +43,24 @@ tempfile = "3"
unsafe_code = "deny"

[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
correctness = "deny"
suspicious = "deny"
# Untrusted-input parser: keep the panic-free denies in production
# (tests opt out via #![cfg_attr(test, allow(...))] in each lib.rs).
unwrap_used = { level = "deny", priority = 0 }
expect_used = { level = "deny", priority = 0 }
# Pragmatic allows (priority 1) — silence pedantic noise that isn't a real defect:
module_name_repetitions = { level = "allow", priority = 1 }
must_use_candidate = { level = "allow", priority = 1 }
missing_errors_doc = { level = "allow", priority = 1 }
missing_panics_doc = { level = "allow", priority = 1 }
cast_possible_truncation = { level = "allow", priority = 1 }
cast_possible_wrap = { level = "allow", priority = 1 }
cast_sign_loss = { level = "allow", priority = 1 }
cast_precision_loss = { level = "allow", priority = 1 }
# Style pedantic lints kept as pragmatic allows (not correctness):
too_many_lines = { level = "allow", priority = 1 }
struct_excessive_bools = { level = "allow", priority = 1 }
items_after_statements = { level = "allow", priority = 1 }
85 changes: 80 additions & 5 deletions crates/exec-pe-analysis/src/anomalies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,87 @@ mod tests {
pe.rich_header = None;
pe.size = 1024 * 1024; // 1 MiB — above the 4 KiB threshold
let hits = detect_pe_anomalies(&pe);
// Both the description and the evidence must name the missing Rich header;
// asserting each independently exercises both mapping arms.
assert!(
hits.iter().any(|h| {
h.description.to_lowercase().contains("rich")
|| h.evidence.iter().any(|e| e.to_lowercase().contains("rich"))
}),
"must detect absent Rich header on large binary"
hits.iter()
.any(|h| h.description.to_lowercase().contains("rich")),
"description must name the absent Rich header"
);
assert!(
hits.iter()
.any(|h| h.evidence.iter().any(|e| e.to_lowercase().contains("rich"))),
"evidence must name the absent Rich header"
);
}

/// A raw-size-0, virtual-size-positive section maps to the virtual-only note.
fn virtual_only_section() -> PeSection {
PeSection {
name: ".vonly".to_string(),
virtual_size: 0x1000,
raw_size: 0,
virtual_address: 0x1000,
entropy: 5.0,
is_executable: false,
is_writable: false,
is_readable: true,
}
}

#[test]
fn virtual_only_section_description() {
let pe = make_pe(&[], vec![virtual_only_section()], &[]);
let hits = detect_pe_anomalies(&pe);
assert!(
hits.iter()
.any(|h| h.description.contains(".vonly") && h.description.contains("raw size")),
"virtual-only section must be described"
);
}

#[test]
fn large_virtual_to_raw_ratio_description() {
let sec = PeSection {
name: ".packed".to_string(),
virtual_size: 100_000,
raw_size: 512,
virtual_address: 0x1000,
entropy: 7.9,
is_executable: true,
is_writable: false,
is_readable: true,
};
let pe = make_pe(&[], vec![sec], &[]);
let hits = detect_pe_anomalies(&pe);
assert!(
hits.iter()
.any(|h| h.description.contains(".packed") && h.description.contains("ratio")),
"large virtual/raw ratio must be described"
);
}

#[test]
fn tls_callbacks_description() {
let mut pe = make_pe(&[], vec![make_section(".text", 5.0, true)], &[]);
pe.tls_callback_count = 2;
let hits = detect_pe_anomalies(&pe);
assert!(
hits.iter().any(|h| h.description.contains("TLS callback")),
"TLS callbacks must be described"
);
}

#[test]
fn overlay_description() {
let mut pe = make_pe(&[], vec![make_section(".text", 5.0, true)], &[]);
pe.overlay_offset = Some(0x8000);
pe.overlay_size = Some(4096);
let hits = detect_pe_anomalies(&pe);
assert!(
hits.iter()
.any(|h| h.description.contains("Overlay") && h.description.contains("4096")),
"overlay must be described with its size"
);
}
}
2 changes: 1 addition & 1 deletion crates/exec-pe-analysis/src/dotnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ mod tests {
assert!(!hits.is_empty());
let combined = [hits[0].description.as_str(), &hits[0].evidence.join(" ")].join(" ");
assert!(
combined.contains("4"),
combined.contains('4'),
"evidence or description must mention the callback count (4)"
);
}
Expand Down
3 changes: 2 additions & 1 deletion crates/exec-pe-analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
clippy::missing_panics_doc,
clippy::must_use_candidate
)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

pub mod anomalies;
pub mod anti_debug;
Expand Down Expand Up @@ -191,7 +192,7 @@ mod tests {
let hits = detect_all(&pe);
let ids: Vec<_> = hits.iter().map(|h| h.mitre_technique_id).collect();
let mut sorted = ids.clone();
sorted.sort();
sorted.sort_unstable();
assert_eq!(ids, sorted, "detect_all results must be sorted by MITRE ID");
}

Expand Down
11 changes: 4 additions & 7 deletions crates/exec-pe-analysis/src/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ use crate::{PeDetection, PeDetectionKind};
///
/// Returns a single detection when `pe.overlay_offset` is `Some`.
pub fn detect_overlay(pe: &PeFile) -> Vec<PeDetection> {
let (offset, size) = match (pe.overlay_offset, pe.overlay_size) {
(Some(off), Some(sz)) => (off, sz),
_ => return vec![],
let (Some(offset), Some(size)) = (pe.overlay_offset, pe.overlay_size) else {
return vec![];
};
vec![PeDetection {
kind: PeDetectionKind::OverlayDetected,
Expand Down Expand Up @@ -77,10 +76,8 @@ mod tests {
assert!(!hits.is_empty());
let combined = [hits[0].description.as_str(), &hits[0].evidence.join(" ")].join(" ");
assert!(
combined.contains("2048")
|| combined.contains("2 KiB")
|| combined.contains("2048 bytes"),
"description or evidence must mention overlay size"
combined.contains("2048 bytes"),
"description or evidence must mention overlay size, got: {combined}"
);
}
}
2 changes: 1 addition & 1 deletion crates/exec-pe-analysis/src/ransomware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub fn detect_ransom_note_filenames(pe: &PeFile) -> Vec<PeDetection> {
}

fn string_basename(s: &str) -> &str {
s.rsplit(|c| c == '\\' || c == '/').next().unwrap_or(s)
s.rsplit(['\\', '/']).next().unwrap_or(s)
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion crates/exec-pe-analysis/src/suspicious_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub fn detect_suspicious_imports(pe: &PeFile) -> Vec<PeDetection> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{make_pe, make_section};
use crate::test_helpers::make_pe;

#[test]
fn virtualalloc_detected() {
Expand Down
4 changes: 2 additions & 2 deletions crates/exec-pe-analysis/src/tls_callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ mod tests {
let pe = make_pe_with_tls(4);
let hits = detect_tls_callbacks(&pe);
assert!(!hits.is_empty());
assert!(hits[0].description.contains("4"));
assert!(hits[0].description.contains('4'));
}

#[test]
Expand All @@ -75,7 +75,7 @@ mod tests {
let hits = detect_tls_callbacks(&pe);
assert!(!hits.is_empty());
assert!(
hits[0].evidence.iter().any(|e| e.contains("3")),
hits[0].evidence.iter().any(|e| e.contains('3')),
"evidence must mention the number of callbacks"
);
}
Expand Down
19 changes: 10 additions & 9 deletions crates/exec-pe-core/src/anomalies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ pub fn detect_structural_anomalies(pe: &PeFile) -> Vec<PeAnomaly> {
});
}

// Large virtual/raw ratio (> 10×) — indicates decompression
if sec.raw_size > 0 {
let ratio = sec.virtual_size / sec.raw_size;
// Large virtual/raw ratio (> 10×) — indicates decompression.
// `checked_div` yields `None` when raw_size == 0 (skipping div-by-zero).
if let Some(ratio) = sec.virtual_size.checked_div(sec.raw_size) {
if ratio > 10 {
out.push(PeAnomaly::LargeVirtualToRawRatio {
section_name: sec.name.clone(),
Expand All @@ -88,12 +88,13 @@ pub fn detect_structural_anomalies(pe: &PeFile) -> Vec<PeAnomaly> {
}

// Entry point outside all sections (only meaningful when sections exist and EP is non-zero)
if pe.entry_point_rva > 0 && !pe.sections.is_empty() {
if !entry_point_in_section(pe.entry_point_rva, &pe.sections) {
out.push(PeAnomaly::EntryPointOutsideSections {
entry_point_rva: pe.entry_point_rva,
});
}
if pe.entry_point_rva > 0
&& !pe.sections.is_empty()
&& !entry_point_in_section(pe.entry_point_rva, &pe.sections)
{
out.push(PeAnomaly::EntryPointOutsideSections {
entry_point_rva: pe.entry_point_rva,
});
}

// TLS callbacks registered
Expand Down
1 change: 1 addition & 0 deletions crates/exec-pe-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

pub mod anomalies;
pub mod error;
Expand Down
Loading
Loading