From 6842bf2b9bd94367d0c8fde047e961723329e320 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 08:17:21 +0800 Subject: [PATCH 1/6] fix(clippy): resolve clippy -D warnings and declare true MSRV 1.85 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker A — clippy --all-targets --all-features -- -D warnings: - Auto-fixed (cargo clippy --fix): cast_lossless (u32/u16 -> From), manual_range_contains, redundant closures, single_char_pattern, collapsible_if, unused imports. - Root-cause: manual_checked_ops -> checked_div (anomalies.rs); manual_let_else -> let...else (overlay.rs). - Removed a provably-dead no-op line in parser.rs test helper (pe[0x56] = if is_dll {0x22|0x20} else {0x22}; both branches == 0x22 and overwritten two lines later). - Fleet [workspace.lints.clippy] recipe: correctness/suspicious deny, unwrap_used/expect_used deny (parser keeps panic-free denies), and pragmatic allows (module_name_repetitions, must_use_candidate, missing_errors/panics_doc, cast_* , too_many_lines, struct_excessive_bools, items_after_statements). - Tests opt out of the panic denies via #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]. Blocker B — true MSRV: goblin 0.10 -> scroll_derive 0.13.x is edition-2024 (needs 1.85). Declared rust-version 1.80 was false; set to 1.85 (1.84 fails on scroll_derive, 1.85 builds). Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 28 ++++++++++++++++--- crates/exec-pe-analysis/src/dotnet.rs | 2 +- crates/exec-pe-analysis/src/lib.rs | 3 +- crates/exec-pe-analysis/src/overlay.rs | 5 ++-- crates/exec-pe-analysis/src/ransomware.rs | 2 +- .../src/suspicious_imports.rs | 2 +- crates/exec-pe-analysis/src/tls_callbacks.rs | 4 +-- crates/exec-pe-core/src/anomalies.rs | 19 +++++++------ crates/exec-pe-core/src/lib.rs | 1 + crates/exec-pe-core/src/parser.rs | 8 ++---- crates/exec-pe-core/src/rich_header.rs | 3 +- crates/exec-pe-core/src/strings.rs | 13 ++++----- 12 files changed, 53 insertions(+), 37 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fc5f502..4b79635 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "] repository = "https://github.com/SecurityRonin/exec-pe-forensic" @@ -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 } diff --git a/crates/exec-pe-analysis/src/dotnet.rs b/crates/exec-pe-analysis/src/dotnet.rs index 122e4d5..d8226cf 100644 --- a/crates/exec-pe-analysis/src/dotnet.rs +++ b/crates/exec-pe-analysis/src/dotnet.rs @@ -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)" ); } diff --git a/crates/exec-pe-analysis/src/lib.rs b/crates/exec-pe-analysis/src/lib.rs index 63f768a..773aa66 100644 --- a/crates/exec-pe-analysis/src/lib.rs +++ b/crates/exec-pe-analysis/src/lib.rs @@ -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; @@ -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"); } diff --git a/crates/exec-pe-analysis/src/overlay.rs b/crates/exec-pe-analysis/src/overlay.rs index 523b0c9..0ca239d 100644 --- a/crates/exec-pe-analysis/src/overlay.rs +++ b/crates/exec-pe-analysis/src/overlay.rs @@ -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 { - 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, diff --git a/crates/exec-pe-analysis/src/ransomware.rs b/crates/exec-pe-analysis/src/ransomware.rs index 579543b..3833e04 100644 --- a/crates/exec-pe-analysis/src/ransomware.rs +++ b/crates/exec-pe-analysis/src/ransomware.rs @@ -67,7 +67,7 @@ pub fn detect_ransom_note_filenames(pe: &PeFile) -> Vec { } fn string_basename(s: &str) -> &str { - s.rsplit(|c| c == '\\' || c == '/').next().unwrap_or(s) + s.rsplit(['\\', '/']).next().unwrap_or(s) } #[cfg(test)] diff --git a/crates/exec-pe-analysis/src/suspicious_imports.rs b/crates/exec-pe-analysis/src/suspicious_imports.rs index 722d6c8..802c5df 100644 --- a/crates/exec-pe-analysis/src/suspicious_imports.rs +++ b/crates/exec-pe-analysis/src/suspicious_imports.rs @@ -25,7 +25,7 @@ pub fn detect_suspicious_imports(pe: &PeFile) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::{make_pe, make_section}; + use crate::test_helpers::make_pe; #[test] fn virtualalloc_detected() { diff --git a/crates/exec-pe-analysis/src/tls_callbacks.rs b/crates/exec-pe-analysis/src/tls_callbacks.rs index c9242c1..de0eda7 100644 --- a/crates/exec-pe-analysis/src/tls_callbacks.rs +++ b/crates/exec-pe-analysis/src/tls_callbacks.rs @@ -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] @@ -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" ); } diff --git a/crates/exec-pe-core/src/anomalies.rs b/crates/exec-pe-core/src/anomalies.rs index ce50938..544bcb9 100644 --- a/crates/exec-pe-core/src/anomalies.rs +++ b/crates/exec-pe-core/src/anomalies.rs @@ -75,9 +75,9 @@ pub fn detect_structural_anomalies(pe: &PeFile) -> Vec { }); } - // 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(), @@ -88,12 +88,13 @@ pub fn detect_structural_anomalies(pe: &PeFile) -> Vec { } // 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 diff --git a/crates/exec-pe-core/src/lib.rs b/crates/exec-pe-core/src/lib.rs index 42c4904..d9af30e 100644 --- a/crates/exec-pe-core/src/lib.rs +++ b/crates/exec-pe-core/src/lib.rs @@ -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; diff --git a/crates/exec-pe-core/src/parser.rs b/crates/exec-pe-core/src/parser.rs index d3d6189..ffd487c 100644 --- a/crates/exec-pe-core/src/parser.rs +++ b/crates/exec-pe-core/src/parser.rs @@ -199,7 +199,7 @@ pub fn parse_pe(bytes: &[u8]) -> Result { .sections .iter() .filter(|s| s.size_of_raw_data > 0) - .map(|s| s.pointer_to_raw_data as u64 + s.size_of_raw_data as u64) + .map(|s| u64::from(s.pointer_to_raw_data) + u64::from(s.size_of_raw_data)) .max() .unwrap_or(0); let file_size = bytes.len() as u64; @@ -269,10 +269,8 @@ pub(crate) mod test_helpers { pe[0x45] = 0x86; // Machine = AMD64 pe[0x48..0x4C].copy_from_slice(×tamp.to_le_bytes()); // TimeDateStamp pe[0x54] = 0xF0; // SizeOfOptionalHeader = 240 - // Characteristics: bit 1 = EXE, bit 5 = large addr, bit 13 = DLL - pe[0x56] = if is_dll { 0x22 | 0x20 } else { 0x22 }; // 0x22 = exe+large, 0x20 = DLL... wait - // Actually: IMAGE_FILE_EXECUTABLE_IMAGE = 0x0002, IMAGE_FILE_LARGE_ADDRESS_AWARE = 0x0020 - // IMAGE_FILE_DLL = 0x2000 + // Characteristics: IMAGE_FILE_EXECUTABLE_IMAGE = 0x0002, + // IMAGE_FILE_LARGE_ADDRESS_AWARE = 0x0020, IMAGE_FILE_DLL = 0x2000 if is_dll { let chars: u16 = 0x2022; // DLL | EXECUTABLE | LARGE_ADDRESS_AWARE pe[0x56..0x58].copy_from_slice(&chars.to_le_bytes()); diff --git a/crates/exec-pe-core/src/rich_header.rs b/crates/exec-pe-core/src/rich_header.rs index ab83417..39375a0 100644 --- a/crates/exec-pe-core/src/rich_header.rs +++ b/crates/exec-pe-core/src/rich_header.rs @@ -120,7 +120,6 @@ pub(crate) fn find_pattern(haystack: &[u8], needle: &[u8]) -> Option { #[cfg(test)] pub(crate) mod test_helpers { - use super::*; /// Build raw bytes containing a valid Rich header in the DOS stub area. /// @@ -138,7 +137,7 @@ pub(crate) mod test_helpers { } // Entries for &(prod, build, count) in entries { - let comp_id = ((prod as u32) << 16) | (build as u32); + let comp_id = (u32::from(prod) << 16) | u32::from(build); stub.extend_from_slice(&(comp_id ^ xor_key).to_le_bytes()); stub.extend_from_slice(&(count ^ xor_key).to_le_bytes()); } diff --git a/crates/exec-pe-core/src/strings.rs b/crates/exec-pe-core/src/strings.rs index 65b1acf..ba672d0 100644 --- a/crates/exec-pe-core/src/strings.rs +++ b/crates/exec-pe-core/src/strings.rs @@ -36,7 +36,7 @@ pub fn extract_ascii(bytes: &[u8], min_len: usize) -> Vec { let mut results = Vec::new(); let mut current = String::new(); for &b in bytes { - if b >= 0x20 && b <= 0x7E { + if (0x20..=0x7E).contains(&b) { current.push(b as char); } else { if current.len() >= min_len { @@ -63,7 +63,7 @@ pub fn extract_utf16le(bytes: &[u8], min_len: usize) -> Vec { while i + 1 < bytes.len() { let lo = bytes[i]; let hi = bytes[i + 1]; - if hi == 0x00 && lo >= 0x20 && lo <= 0x7E { + if hi == 0x00 && (0x20..=0x7E).contains(&lo) { current.push(lo as char); i += 2; } else { @@ -141,10 +141,7 @@ mod tests { #[test] fn utf16le_extracts_simple_string() { // "Hello" as UTF-16LE - let input: Vec = "Hello!" - .encode_utf16() - .flat_map(|c| c.to_le_bytes()) - .collect(); + let input: Vec = "Hello!".encode_utf16().flat_map(u16::to_le_bytes).collect(); let strings = extract_utf16le(&input, 6); assert!( strings.contains(&"Hello!".to_string()), @@ -160,7 +157,7 @@ mod tests { #[test] fn utf16le_skips_short_runs() { // "AB" as UTF-16LE — only 2 chars, below min_len - let input: Vec = "AB".encode_utf16().flat_map(|c| c.to_le_bytes()).collect(); + let input: Vec = "AB".encode_utf16().flat_map(u16::to_le_bytes).collect(); let strings = extract_utf16le(&input, 6); assert!( strings.iter().all(|s| s.len() >= 6), @@ -171,7 +168,7 @@ mod tests { #[test] fn utf16le_mixed_with_binary_extracts_only_strings() { let mut buf: Vec = vec![0xDE, 0xAD, 0xBE, 0xEF]; - buf.extend("VirtualAlloc".encode_utf16().flat_map(|c| c.to_le_bytes())); + buf.extend("VirtualAlloc".encode_utf16().flat_map(u16::to_le_bytes)); buf.extend_from_slice(&[0xFF, 0xFE]); let strings = extract_utf16le(&buf, 6); assert!(strings.contains(&"VirtualAlloc".to_string())); From 5510e66decd9200cf25992f5d1131c5c02c73dee Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 08:17:34 +0800 Subject: [PATCH 2/6] ci: add fleet-standard CI workflow Jobs (SHA-pinned actions; Swatinem/rust-cache after each toolchain step in compiling jobs): fmt (cargo fmt --check), clippy (--all-targets --all-features -D warnings), test (ubuntu/macos/windows matrix, --all-features), check-msrv (1.85.0, cargo build --all-features --locked), coverage (cargo llvm-cov, advisory/continue-on-error with a TODO -- ~94% at adoption), and cargo-deny (bans/licenses/sources + advisories). vet.yml left untouched. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ab932f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +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 (advisory) + runs-on: ubuntu-latest + # TODO: promote to a hard gate once the workspace reaches 100% line coverage + # (was ~94% at CI adoption). Advisory for now so it never blocks a green PR. + continue-on-error: true + 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 + - run: cargo llvm-cov --all-features --summary-only + + 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 + # Informational: a malformed entry in the upstream RustSec advisory-db breaks + # DB *loading* for the whole ecosystem and must not gate our build. + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + command: check advisories From f28c86e853f94bf4fb525c658037ce1dd40eb802 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 09:12:07 +0800 Subject: [PATCH 3/6] test(coverage): reach 100% line coverage; promote coverage to hard gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the previously-untested production paths across the workspace with real, behavior-validating tests: exec-pe-core: - strings::compute_entropy — empty/uniform/equiprobable/full-range distributions. - parser — a section-bearing PE (section fields + overlay detection), a PE with no optional header (entry/base/checksum fall back to zero), and a CodeView IMAGE_DEBUG_DIRECTORY carrying an RSDS PDB path. All are hand-built PEs whose ground truth is derivable from the construction and parsed by goblin. - rich_header — DanS found after a leading junk DWORD (scan-advance path) and a DanS-immediately-before-Rich header (empty-but-valid). The if-let None arm in the DanS scan is provably dead under the while-guard invariant, marked // cov:unreachable. - canonical_finding_tests — every remaining PeAnomaly variant maps to its documented canonical Finding (severity/category/code/note/mitre/evidence). exec-pe-analysis: - anomalies — describe_anomaly for the virtual-only / large-ratio / TLS / overlay variants; split a short-circuiting Rich-header assertion so both arms run. - overlay — assert the exact size substring the detector emits. Flip the coverage CI job from advisory (continue-on-error) to the fleet hard gate: fail on any uncovered production line (DA:n,0), honoring cov:unreachable. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 33 +++++- crates/exec-pe-analysis/src/anomalies.rs | 85 ++++++++++++++- crates/exec-pe-analysis/src/overlay.rs | 6 +- crates/exec-pe-core/src/parser.rs | 101 ++++++++++++++++++ crates/exec-pe-core/src/rich_header.rs | 57 +++++++++- crates/exec-pe-core/src/strings.rs | 27 +++++ .../tests/canonical_finding_tests.rs | 72 +++++++++++++ 7 files changed, 366 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ab932f..5df98c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,11 +61,8 @@ jobs: - run: cargo +1.85.0 build --all-features --locked coverage: - name: Coverage (advisory) + name: Coverage (100% production lines) runs-on: ubuntu-latest - # TODO: promote to a hard gate once the workspace reaches 100% line coverage - # (was ~94% at CI adoption). Advisory for now so it never blocks a green PR. - continue-on-error: true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @@ -76,7 +73,33 @@ jobs: uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 with: tool: cargo-llvm-cov - - run: cargo llvm-cov --all-features --summary-only + - 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) diff --git a/crates/exec-pe-analysis/src/anomalies.rs b/crates/exec-pe-analysis/src/anomalies.rs index 6166dd7..695c457 100644 --- a/crates/exec-pe-analysis/src/anomalies.rs +++ b/crates/exec-pe-analysis/src/anomalies.rs @@ -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" ); } } diff --git a/crates/exec-pe-analysis/src/overlay.rs b/crates/exec-pe-analysis/src/overlay.rs index 0ca239d..3db0b49 100644 --- a/crates/exec-pe-analysis/src/overlay.rs +++ b/crates/exec-pe-analysis/src/overlay.rs @@ -76,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}" ); } } diff --git a/crates/exec-pe-core/src/parser.rs b/crates/exec-pe-core/src/parser.rs index ffd487c..ec2934a 100644 --- a/crates/exec-pe-core/src/parser.rs +++ b/crates/exec-pe-core/src/parser.rs @@ -524,4 +524,105 @@ mod tests { let pe = parse_pe(&bytes).expect("minimal PE"); assert!(pe.rich_header.is_none()); } + + // ── section / overlay / debug / no-optional-header extraction ────────────── + + /// Write a 40-byte section header at the section table (0x148, immediately + /// after the 240-byte PE32+ optional header) into a minimal PE. + fn write_section( + pe: &mut [u8], + name: &[u8], + virtual_size: u32, + virtual_address: u32, + raw_size: u32, + raw_ptr: u32, + characteristics: u32, + ) { + let sh = 0x148; + pe[sh..sh + name.len()].copy_from_slice(name); + pe[sh + 0x08..sh + 0x0C].copy_from_slice(&virtual_size.to_le_bytes()); + pe[sh + 0x0C..sh + 0x10].copy_from_slice(&virtual_address.to_le_bytes()); + pe[sh + 0x10..sh + 0x14].copy_from_slice(&raw_size.to_le_bytes()); + pe[sh + 0x14..sh + 0x18].copy_from_slice(&raw_ptr.to_le_bytes()); + pe[sh + 0x24..sh + 0x28].copy_from_slice(&characteristics.to_le_bytes()); + } + + #[test] + fn section_fields_and_overlay_extracted() { + // One executable section [raw 0x200..0x300) plus 16 trailing overlay bytes. + let mut pe = make_minimal_pe_x64(0, false); + pe[0x46] = 1; // NumberOfSections = 1 + pe[0x90] = 0x00; + pe[0x91] = 0x20; // SizeOfImage = 0x2000 + write_section(&mut pe, b".text", 0x100, 0x1000, 0x100, 0x200, 0x6000_0020); + pe.resize(0x300, 0); // section raw data + pe.extend_from_slice(&[0xAA; 0x10]); // overlay + + let parsed = parse_pe(&pe).expect("section-bearing PE"); + assert_eq!(parsed.sections.len(), 1); + let sec = &parsed.sections[0]; + assert_eq!(sec.name, ".text"); + assert_eq!(sec.virtual_address, 0x1000); + assert_eq!(sec.raw_size, 0x100); + assert!(sec.is_executable); + assert!(sec.is_readable); + assert!(!sec.is_writable); + // Overlay begins right after the last section's raw data. + assert_eq!(parsed.overlay_offset, Some(0x300)); + assert_eq!(parsed.overlay_size, Some(0x10)); + } + + #[test] + fn pe_without_optional_header_defaults_entry_base_checksum_to_zero() { + // A PE with SizeOfOptionalHeader = 0 parses without an optional header; + // entry-point / image-base / checksum then fall back to zero. + let mut pe = make_minimal_pe_x64(0, false); + pe[0x54] = 0; + pe[0x55] = 0; // SizeOfOptionalHeader = 0 + for b in &mut pe[0x58..0x148] { + *b = 0; + } + let parsed = parse_pe(&pe).expect("PE with no optional header"); + assert_eq!(parsed.entry_point_rva, 0); + assert_eq!(parsed.image_base, 0); + assert_eq!(parsed.checksum, 0); + } + + #[test] + fn pdb_path_extracted_from_codeview_debug_directory() { + // Build a .rdata section holding an IMAGE_DEBUG_DIRECTORY (Type=CODEVIEW) + // that points at an RSDS CV_INFO_PDB70 record carrying the PDB path. + let mut pe = make_minimal_pe_x64(0, false); + pe[0x46] = 1; + pe[0x90] = 0x00; + pe[0x91] = 0x20; // SizeOfImage = 0x2000 + write_section(&mut pe, b".rdata", 0x200, 0x1000, 0x200, 0x200, 0x4000_0040); + // Data directory[6] (DEBUG): RVA 0x1000, size 28 (one debug dir entry). + let dd = 0xC8 + 6 * 8; + pe[dd..dd + 4].copy_from_slice(&0x1000u32.to_le_bytes()); + pe[dd + 4..dd + 8].copy_from_slice(&28u32.to_le_bytes()); + pe.resize(0x400, 0); + + // IMAGE_DEBUG_DIRECTORY at file 0x200 (RVA 0x1000). + let d = 0x200; + pe[d + 12..d + 16].copy_from_slice(&2u32.to_le_bytes()); // Type = CODEVIEW + let cv_rva = 0x1000u32 + 28; + let cv_ptr = 0x200u32 + 28; + pe[d + 16..d + 20].copy_from_slice(&64u32.to_le_bytes()); // SizeOfData (covers full path) + pe[d + 20..d + 24].copy_from_slice(&cv_rva.to_le_bytes()); // AddressOfRawData + pe[d + 24..d + 28].copy_from_slice(&cv_ptr.to_le_bytes()); // PointerToRawData + + // CV_INFO_PDB70 at file 0x21C: "RSDS" + GUID(16) + Age(4) + NUL-term path. + let c = 0x21C; + pe[c..c + 4].copy_from_slice(&0x5344_5352u32.to_le_bytes()); // "RSDS" + for (i, b) in pe[c + 4..c + 20].iter_mut().enumerate() { + *b = i as u8; // GUID bytes + } + pe[c + 20..c + 24].copy_from_slice(&1u32.to_le_bytes()); // Age + let name = b"C:\\build\\payload.pdb\0"; + pe[c + 24..c + 24 + name.len()].copy_from_slice(name); + + let parsed = parse_pe(&pe).expect("PE with CodeView debug dir"); + assert_eq!(parsed.pdb_path.as_deref(), Some("C:\\build\\payload.pdb")); + } } diff --git a/crates/exec-pe-core/src/rich_header.rs b/crates/exec-pe-core/src/rich_header.rs index 39375a0..731e489 100644 --- a/crates/exec-pe-core/src/rich_header.rs +++ b/crates/exec-pe-core/src/rich_header.rs @@ -75,7 +75,7 @@ pub fn parse_rich_header(bytes: &[u8]) -> Option { dans_rel = Some(i); break; } - } + } // cov:unreachable: while-guard i+4 <= rich_rel, and rich_rel+4 <= stub_area.len() (find_pattern of a 4-byte needle), so read_u32_le is always Some here i += 4; } let dans_rel = dans_rel?; @@ -228,4 +228,59 @@ mod tests { let rh = parse_rich_header(&buf).unwrap(); assert_eq!(rh.xor_key, expected_key); } + + const DANS: u32 = 0x536E_6144; + + /// Wrap a hand-built stub area (bytes from 0x40) into an MZ buffer whose + /// `e_lfanew` points just past it at a `PE\0\0` signature. + fn wrap_stub(stub: &[u8]) -> Vec { + let e_lfanew = 0x40 + stub.len() as u32; + let mut buf = vec![0u8; e_lfanew as usize + 4]; + buf[0] = b'M'; + buf[1] = b'Z'; + buf[0x3C..0x40].copy_from_slice(&e_lfanew.to_le_bytes()); + buf[0x40..e_lfanew as usize].copy_from_slice(stub); + buf[e_lfanew as usize..].copy_from_slice(b"PE\0\0"); + buf + } + + #[test] + fn dans_marker_found_after_leading_junk_dword() { + // A non-DanS DWORD precedes DanS, so the scan must advance past it (the + // no-match branch of the DanS search) before matching. + let key = 0x1122_3344_u32; + let mut stub = Vec::new(); + stub.extend_from_slice(&0u32.to_le_bytes()); // 0 ^ key = key != DanS + stub.extend_from_slice(&(DANS ^ key).to_le_bytes()); // DanS at rel 4 + for _ in 0..3 { + stub.extend_from_slice(&key.to_le_bytes()); // 3 padding DWORDs + } + let comp_id = (0x0103u32 << 16) | 0x6B6B; + stub.extend_from_slice(&(comp_id ^ key).to_le_bytes()); + stub.extend_from_slice(&(7u32 ^ key).to_le_bytes()); + stub.extend_from_slice(b"Rich"); + stub.extend_from_slice(&key.to_le_bytes()); + + let rh = parse_rich_header(&wrap_stub(&stub)).expect("DanS found after junk"); + assert_eq!(rh.xor_key, key); + assert_eq!(rh.entries.len(), 1); + assert_eq!(rh.entries[0].product_id, 0x0103); + assert_eq!(rh.entries[0].use_count, 7); + } + + #[test] + fn dans_immediately_before_rich_yields_empty_entries() { + // DanS sits right before "Rich": entries_start (dans_rel + 16) exceeds the + // "Rich" position, so the header parses as valid-but-empty rather than + // reading past it. + let key = 0xAABB_CCDD_u32; + let mut stub = Vec::new(); + stub.extend_from_slice(&(DANS ^ key).to_le_bytes()); // DanS at rel 0 + stub.extend_from_slice(b"Rich"); // rich_rel = 4 < entries_start (16) + stub.extend_from_slice(&key.to_le_bytes()); + + let rh = parse_rich_header(&wrap_stub(&stub)).expect("empty-but-valid Rich header"); + assert!(rh.entries.is_empty()); + assert_eq!(rh.xor_key, key); + } } diff --git a/crates/exec-pe-core/src/strings.rs b/crates/exec-pe-core/src/strings.rs index ba672d0..d04d9af 100644 --- a/crates/exec-pe-core/src/strings.rs +++ b/crates/exec-pe-core/src/strings.rs @@ -84,6 +84,33 @@ pub fn extract_utf16le(bytes: &[u8], min_len: usize) -> Vec { mod tests { use super::*; + // ── compute_entropy ─────────────────────────────────────────────────────── + + #[test] + fn entropy_of_empty_is_zero() { + assert!(compute_entropy(&[]).abs() < 1e-6); + } + + #[test] + fn entropy_of_uniform_bytes_is_zero() { + // A single repeated symbol carries no information -> 0 bits. + assert!(compute_entropy(&[0x41; 64]).abs() < 1e-6); + } + + #[test] + fn entropy_of_two_equiprobable_symbols_is_one_bit() { + // Two symbols at p=0.5 each -> exactly 1.0 bit/byte. + let data: Vec = (0..256u32).map(|i| (i % 2) as u8).collect(); + assert!((compute_entropy(&data) - 1.0).abs() < 1e-6); + } + + #[test] + fn entropy_of_all_256_values_is_eight_bits() { + // A uniform distribution over all 256 byte values -> maximal 8.0 bits. + let data: Vec = (0..=255u8).collect(); + assert!((compute_entropy(&data) - 8.0).abs() < 1e-6); + } + // ── extract_ascii ───────────────────────────────────────────────────────── #[test] diff --git a/crates/exec-pe-core/tests/canonical_finding_tests.rs b/crates/exec-pe-core/tests/canonical_finding_tests.rs index ca80196..4136dd8 100644 --- a/crates/exec-pe-core/tests/canonical_finding_tests.rs +++ b/crates/exec-pe-core/tests/canonical_finding_tests.rs @@ -31,3 +31,75 @@ fn rich_header_absent_is_graded_low_concealment() { assert_eq!(f.severity, Some(Severity::Low)); assert_eq!(f.category, Category::Concealment); } + +fn src() -> Source { + Source { + analyzer: "exec-pe-forensic".to_string(), + scope: "PE".to_string(), + version: None, + } +} + +/// Every remaining `PeAnomaly` variant normalizes to its documented canonical +/// finding — exercising the severity/category/code/note/mitre/evidence mapping +/// arms for each. Correctness here is defined by the reporting spec (the code + +/// grade table), so this validates the mapping rule, not just line execution. +#[test] +fn each_anomaly_variant_maps_to_expected_canonical_finding() { + use forensicnomicon::report::Category; + + // W+X section: Medium / Structure / T1055, evidence names the section. + let f = PeAnomaly::WritableExecutableSection { + section_name: ".rwx".to_string(), + } + .to_finding(src()); + assert_eq!(f.code, "PE-WX-SECTION"); + assert_eq!(f.severity, Some(Severity::Medium)); + assert_eq!(f.category, Category::Structure); + assert!(f.note.contains(".rwx")); + assert!(f.context.external_refs.iter().any(|r| r.id == "T1055")); + assert!(f.evidence.iter().any(|e| e.field == "section")); + + // Virtual-only section: Medium / Structure / T1027.002. + let f = PeAnomaly::VirtualOnlySection { + section_name: ".bss".to_string(), + } + .to_finding(src()); + assert_eq!(f.code, "PE-VIRTUAL-ONLY-SECTION"); + assert_eq!(f.severity, Some(Severity::Medium)); + assert!(f.context.external_refs.iter().any(|r| r.id == "T1027.002")); + assert!(f.evidence.iter().any(|e| e.field == "section")); + + // Large virtual/raw ratio: Medium, evidence carries both section and ratio. + let f = PeAnomaly::LargeVirtualToRawRatio { + section_name: ".packed".to_string(), + ratio: 42, + } + .to_finding(src()); + assert_eq!(f.code, "PE-VSIZE-RATIO"); + assert_eq!(f.severity, Some(Severity::Medium)); + assert!(f.context.external_refs.iter().any(|r| r.id == "T1027.002")); + assert!(f.evidence.iter().any(|e| e.field == "ratio")); + assert!(f.note.contains("42")); + + // TLS callbacks: Low / Concealment / T1055.005. + let f = PeAnomaly::TlsCallbacksPresent { count: 3 }.to_finding(src()); + assert_eq!(f.code, "PE-TLS-CALLBACKS"); + assert_eq!(f.severity, Some(Severity::Low)); + assert_eq!(f.category, Category::Concealment); + assert!(f.context.external_refs.iter().any(|r| r.id == "T1055.005")); + assert!(f.evidence.iter().any(|e| e.field == "count")); + + // Overlay: Low / Residue / no MITRE technique, evidence has size + offset. + let f = PeAnomaly::OverlayPresent { + offset: 0x8000, + size: 512, + } + .to_finding(src()); + assert_eq!(f.code, "PE-OVERLAY"); + assert_eq!(f.severity, Some(Severity::Low)); + assert_eq!(f.category, Category::Residue); + assert!(f.context.external_refs.is_empty()); + assert!(f.evidence.iter().any(|e| e.field == "size")); + assert!(f.evidence.iter().any(|e| e.field == "offset")); +} From ba665d6aba47757652b85d4b051cc3123bd6214a Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 17:24:15 +0800 Subject: [PATCH 4/6] fix(deps): bump anyhow 1.0.102 -> 1.0.104 (RUSTSEC-2026-0190) anyhow <1.0.103 has an unsoundness (UB) in Error::downcast_mut after Error::context (memory-corruption). Patched in 1.0.103; bump to latest 1.0.104. Clears the cargo-deny advisories finding. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cb11ae..d824eec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,12 +1,12 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "bitflags" From e820566e077f479ae46d3206ec9f20564963402b Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 17:28:43 +0800 Subject: [PATCH 5/6] chore(vet): bump anyhow exemption pin 1.0.102 -> 1.0.104 Follows the anyhow lock bump; keeps the existing safe-to-run exemption posture at the patched version so cargo vet --locked passes. Co-Authored-By: Claude Opus 4.8 --- supply-chain/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 2541272..2c5969d 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -23,7 +23,7 @@ audit-as-crates-io = true audit-as-crates-io = true [[exemptions.anyhow]] -version = "1.0.102" +version = "1.0.104" criteria = "safe-to-run" [[exemptions.block-buffer]] From c06973a97fd3ddd1238da0e9228c3827a47125c0 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 18:47:51 +0800 Subject: [PATCH 6/6] ci: make cargo-deny advisories gating (stop masking real advisories) Remove continue-on-error from the advisories job so a real RustSec advisory blocks CI instead of being informational-only. Tradeoff accepted: an upstream advisory-db load failure can now gate, but surfacing genuine advisories (e.g. RUSTSEC-2026-0190 anyhow, already bumped on this branch) is the priority. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5df98c6..1dc872d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,9 +113,6 @@ jobs: advisories: name: cargo-deny (advisories) runs-on: ubuntu-latest - # Informational: a malformed entry in the upstream RustSec advisory-db breaks - # DB *loading* for the whole ecosystem and must not gate our build. - continue-on-error: true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1