From 0790128d91d4477972210d733be47bf3d454e2e6 Mon Sep 17 00:00:00 2001 From: Mobin Erteghaie <136170601+mobinert@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:25:24 +0330 Subject: [PATCH] Add case refresh changes and structured PE diff --- .github/release-notes.md.in | 14 ++-- CMakeLists.txt | 2 +- README.md | 57 ++++++++++---- docs/index.html | 43 +++++----- docs/sitemap.xml | 1 + release_notes.md | 31 ++++++++ src/case.hpp | 66 ++++++++++++++++ src/case_io.hpp | 35 ++++++++- src/case_types.hpp | 16 ++++ src/cli.hpp | 8 +- src/diff.hpp | 151 ++++++++++++++++++++++++++++++++---- src/gui/case.html | 7 +- src/gui/main_gui.cpp | 2 + src/main_cli.cpp | 5 +- src/pe.hpp | 86 ++++++++++++-------- tests/direct_smoke.ps1 | 41 +++++++++- tests/release_checks.ps1 | 19 +++++ tests/test_case.cpp | 48 ++++++++++++ tests/test_diff.cpp | 66 ++++++++++++++++ tests/test_pe.cpp | 97 +++++++++++++++++++++++ 20 files changed, 695 insertions(+), 100 deletions(-) diff --git a/.github/release-notes.md.in b/.github/release-notes.md.in index eb9307f..93e4452 100644 --- a/.github/release-notes.md.in +++ b/.github/release-notes.md.in @@ -1,13 +1,11 @@ -Horus v@PROJECT_VERSION@ adds persistent case analysis without changing the direct workflow: dropping a file onto the executable or running `horus.exe sample.exe` still analyzes it immediately. +HORUS v@PROJECT_VERSION@ makes repeat case triage easier to review and PE comparisons easier to automate. Highlights: -- Persistent Case Mode for up to 5,000 samples, with incremental refresh, verify, rebuild, prune, portable state, and offline HTML/CSV/JSON reports. -- Exact duplicate groups for every file and explainable PE similarity clusters based on TLSH, imphash, Rich hash, and section layout. -- Content-based direct analysis for files with any extension, including drag-and-drop and paths with spaces. -- Explicit `UNKNOWN` and `analysis_status` output when evidence is unavailable, plus documented exit code 3 for incomplete results. -- Opt-in network enrichment with `--enrich`; only hashes and extracted indicators are sent, never file contents. -- Offline/cache-only Authenticode trust by default, with online revocation available through `--online-trust`. -- Hardened report embedding, strict JSON parsing, bounded HTTP responses, atomic state/export writes, and CSV formula protection. +- Each case refresh records what changed in the current run: added, modified, restored, missing, removed by `--prune`, and assessment-changed samples. Review the `changes` array and `summary.changes` in `case.json`, the new `changes.csv`, or the offline dashboard. +- The feed covers the latest refresh, not a historical audit log. Use `--verify` to detect edits that preserve both file size and modification time. +- `horus --diff a.exe b.exe --json` provides a machine-readable PE comparison. Incomplete or non-PE comparisons set `analysis_status` to `unknown` and exit 3 instead of implying a complete PE comparison. +- Malformed PE input handling and CLI mode validation are stricter. +- The established direct-file, case, and opt-in enrichment workflows remain available. Samples are never uploaded by HORUS. The three binaries target Windows 10/11 x64. They are not Authenticode-signed; verify downloads with `SHA256SUMS.txt` and GitHub artifact attestations. diff --git a/CMakeLists.txt b/CMakeLists.txt index ba7a316..a18136f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.15) if(POLICY CMP0091) cmake_policy(SET CMP0091 NEW) endif() -project(horus VERSION 4.0.0 LANGUAGES CXX) +project(horus VERSION 4.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 7433d17..d281f42 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ Hand it **anything** and it figures out what to do: Direct file analysis needs no mode flag: `horus.exe sample.exe` runs immediately. Network access is always opt-in through `--enrich`. +### New in v4.1 + +- **Case change feed** — each refresh records files added, modified, restored, newly missing, or removed by `--prune`, plus changed assessments. Inspect the current run in `case.json`, `changes.csv`, or the offline dashboard. +- **Machine-readable PE diff** — `horus --diff a.exe b.exe --json` returns structured comparison data. Incomplete or non-PE comparisons set `analysis_status` to `unknown` and exit 3. +- **Stricter input handling** — additional malformed-PE bounds checks and rejection of conflicting `--gui`/`--html` combinations with scan, diff, or each other. + ### New in v4.0 - **Persistent Case Mode** — analyze up to 5,000 files with incremental refresh, `--verify`, `--rebuild`, `--prune`, portable relative paths, and atomic state. @@ -88,6 +94,8 @@ Direct file analysis needs no mode flag: `horus.exe sample.exe` runs immediately ## Terminal Preview +Illustrative output only. The file, indicators, provider result, and verdict below are fictional; no scan or provider lookup produced this example. + ``` _ _ ___ ____ _ _ ____ | | | |/ _ \| _ \| | | / ___| @@ -103,7 +111,7 @@ FILE ───────────────────────── size 102,912 bytes MD5 a3f5d1e2c7b849d60e14a2b8f93c5d17 SHA-1 4b2e8a91c3d056f7e820b4a19c6d3f7e2b085c40 - SHA-256 e3b0c44298fc1c149afbf4c8996fb924...852b855 + SHA-256 470735e5b9545078198d440e67abbf86...dad3f57d PE HEADER ─────────────────────────────────────────────────────── type executable @@ -143,8 +151,8 @@ CAPABILITIES ────────────────────── ! Known packer section name 'UPX0' +16 EMBEDDED INDICATORS (from strings) ──────────────────────────── - url http://185.220.101.5:8080/gate.php - ip 185.220.101.5 + url http://192.0.2.42:8080/gate.php + ip 192.0.2.42 tokens cmd.exe schtasks powershell THREAT INTELLIGENCE ───────────────────────────────────────────── @@ -253,7 +261,7 @@ options: --no-recurse analyze only the top level of a case directory --online-trust allow online Authenticode revocation checks --scan triage every file in a folder, worst-first - --diff compare two PEs (imphash / TLSH / section entropy) + --diff compare two PEs (add --json for structured output) --clip read the indicator from the clipboard --gui open the glass-console GUI (unified build) --html write the glass-console report to an HTML file @@ -264,7 +272,7 @@ options: --abuse-key AbuseIPDB API key (overrides ABUSEIPDB_API_KEY env) --strings dump extracted ASCII/UTF-16 strings from the file --profile verbose UEBA signal breakdown - --json machine-readable JSON output (exit code 1 = suspicious/malicious) + --json machine-readable JSON (including case and diff modes) --no-color disable ANSI colour (auto-disabled when piped) -h, --help show this help --version print the version @@ -308,33 +316,52 @@ horus.exe --case C:\Cases\incoming --verify --enrich --gui horus.exe --case C:\Cases\incoming --rebuild --cluster-mode conservative ``` -Each workspace contains private state under `.horus` and portable exports named `case.json`, `report.html`, `samples.csv`, `clusters.csv`, and `iocs.csv`. Stored paths are relative to the samples directory. +Each workspace contains private state under `.horus` and portable exports named `case.json`, `report.html`, `samples.csv`, `clusters.csv`, `iocs.csv`, and `changes.csv`. Stored paths are relative to the samples directory. + +On each refresh, the top-level `changes` array in `case.json` and `changes.csv` describe what changed in that run. `summary.changes` gives the event count. Events distinguish `added`, `modified`, `restored`, `missing`, `removed` (with `--prune`), and `assessment_changed`. An unchanged file may get `assessment_changed` when its analysis status, score, verdict, or intel summary changes. Each event includes a relative path and available before/after SHA-256, analysis status, and score. This feed describes the latest refresh, not every past run. Files marked missing stay in case history until pruned. Use `--verify` to detect content changes that preserve both file size and modification time. + +```powershell +$case = Get-Content C:\Cases\incoming.horus-case\case.json -Raw | ConvertFrom-Json +$case.summary.changes +$case.changes | Where-Object kind -eq 'assessment_changed' +``` + +### PE Comparison + +```powershell +horus.exe --diff C:\Samples\a.exe C:\Samples\b.exe +horus.exe --diff C:\Samples\a.exe C:\Samples\b.exe --json +``` + +The JSON form includes each file's analysis status and a `comparison` object with hash matches, TLSH distance, and section entropy drift where available. When one or both files cannot be fully analyzed as PEs, the top-level `analysis_status` is `unknown` and the command exits 3. A file read or usage error exits 2; a complete comparison exits 1 if either file is scored suspicious or malicious, otherwise 0. ### IOC Lookup Examples +The reserved IP addresses and domains below illustrate command syntax; they are not active threat indicators. Live provider results are not guaranteed for them. + ```bat :: MD5, SHA-1, or SHA-256 hash — Horus auto-detects the type horus 44d88612fea8a8f36de82e1278abb02f --enrich horus 3395856ce81f2b7382dee72602f798b642f14d0 --enrich -horus 275a021bbfb6489e54d471899f7db9d1663fc695b2cfad87ef3ef... --enrich +horus 470735e5b9545078198d440e67abbf86a43c29ae579eba1cf632eaa8dad3f57d --enrich :: IP reputation from VirusTotal + AbuseIPDB simultaneously -horus 185.220.101.5 --enrich +horus 192.0.2.42 --enrich :: Domain reputation -horus evil-c2-domain.ru --enrich +horus suspicious.example --enrich :: Full URL horus https://sketchy.example/drop.bin --enrich :: Email address -horus attacker@phishing-domain.com --enrich +horus sender@example.com --enrich :: Combine keys inline instead of via env vars -horus 1.2.3.4 --enrich --vt-key abc123 --abuse-key def456 +horus 192.0.2.42 --enrich --vt-key abc123 --abuse-key def456 :: JSON output for automation -horus 185.220.101.5 --enrich --json +horus 192.0.2.42 --enrich --json ``` ### Scripting / Automation @@ -365,6 +392,8 @@ Get-ChildItem C:\Quarantine\*.exe | ForEach-Object { ### JSON Output Format +Illustrative structure and values; this is not the output of a real sample. + ```json { "target": "malware.exe", @@ -372,7 +401,7 @@ Get-ChildItem C:\Quarantine\*.exe | ForEach-Object { "size": 102912, "md5": "a3f5d1e2c7b849d60e14a2b8f93c5d17", "sha1": "4b2e8a91c3d056f7e820b4a19c6d3f7e2b085c40", - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "sha256": "470735e5b9545078198d440e67abbf86a43c29ae579eba1cf632eaa8dad3f57d", "is_pe": true, "bitness": 64, "imphash": "9a4f2c8d1e7b3056a82c914d6e0f5b17", @@ -533,7 +562,7 @@ The imphash follows the Mandiant recipe: lowercase `dll.function` pairs (with ex ## Safety and Limitations - Horus never executes, copies, modifies, or uploads a sample. It only writes requested reports, case state, and local cache files. -- The PE parser **bounds-checks every offset and length** before reading. Feeding it a malformed or malicious sample is safe. +- The PE parser checks offsets and lengths before reading and rejects malformed structures it detects. Keep Horus updated and analyze untrusted samples in a suitable environment. - Network access is off by default. `--enrich` sends only hashes and extracted indicators to configured providers; `--online-trust` separately enables online certificate revocation checks. - The static risk score is a **triage aid**, not a production verdict. Pair it with independent evidence and, for anything that matters, a controlled sandbox detonation. - Obfuscated samples that load all their imports at runtime will score low on static analysis — that's expected. The entropy and packing heuristics partially compensate, but there's no substitute for dynamic analysis on heavily obfuscated code. diff --git a/docs/index.html b/docs/index.html index 919be10..e345077 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,9 +3,9 @@ -HORUS v4 — persistent case analysis for Windows - - +HORUS v4.1 — Windows malware triage and case change tracking + + @@ -13,8 +13,8 @@ - - + + @@ -22,8 +22,8 @@ - - + + diff --git a/src/gui/main_gui.cpp b/src/gui/main_gui.cpp index 2f9cff5..6d6c3a3 100644 --- a/src/gui/main_gui.cpp +++ b/src/gui/main_gui.cpp @@ -12,6 +12,8 @@ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { horus::core::Options opts = horus::cli::parse_args(__argc, __argv); + if (!opts.scan_dir.empty() || !opts.diff_a.empty()) + horus::cli::argument_error("--scan and --diff require horus.exe or horus-cli.exe"); if (!opts.case_dir.empty()) { opts.gui = true; return horus::cli::run(opts); diff --git a/src/main_cli.cpp b/src/main_cli.cpp index d2f838b..2d49afb 100644 --- a/src/main_cli.cpp +++ b/src/main_cli.cpp @@ -8,5 +8,8 @@ #include "cli.hpp" int main(int argc, char** argv) { - return horus::cli::run(argc, argv); + horus::core::Options opts = horus::cli::parse_args(argc, argv); + if (opts.gui || !opts.html_out.empty()) + horus::cli::argument_error("--gui and --html require horus.exe or horus-gui.exe"); + return horus::cli::run(opts); } diff --git a/src/pe.hpp b/src/pe.hpp index 19fc898..e136324 100644 --- a/src/pe.hpp +++ b/src/pe.hpp @@ -192,28 +192,41 @@ class Analyzer { const size_t opt = fh + 20; // IMAGE_OPTIONAL_HEADER uint16_t optmagic; - if (!rd16(opt, optmagic)) + if (opt > buf_.size() || opt_size > buf_.size() - opt || + opt_size < sizeof(optmagic) || !rd16(opt, optmagic)) return err(info, "truncated optional header"); size_t dir_base; // file offset of DataDirectory[0] if (optmagic == constants::OPT_MAGIC_PE64) { + if (opt_size < 112) + return err(info, "truncated PE32+ optional header"); info.is_64bit = true; - uint32_t ib_lo, ib_hi; - rd32(opt + 0x18, ib_lo); rd32(opt + 0x1C, ib_hi); + uint32_t ib_lo = 0, ib_hi = 0; + if (!rd32(opt + 0x18, ib_lo) || !rd32(opt + 0x1C, ib_hi) || + !rd32(opt + 0x10, info.entry_point_rva)) + return err(info, "truncated PE32+ optional header"); info.image_base = ((uint64_t)ib_hi << 32) | ib_lo; - rd32(opt + 0x10, info.entry_point_rva); dir_base = opt + 112; // PE32+ DataDirectory } else if (optmagic == constants::OPT_MAGIC_PE32) { + if (opt_size < 96) + return err(info, "truncated PE32 optional header"); info.is_64bit = false; - uint32_t ib; rd32(opt + 0x1C, ib); + uint32_t ib = 0; + if (!rd32(opt + 0x1C, ib) || + !rd32(opt + 0x10, info.entry_point_rva)) + return err(info, "truncated PE32 optional header"); info.image_base = ib; - rd32(opt + 0x10, info.entry_point_rva); dir_base = opt + 96; // PE32 DataDirectory } else { return err(info, "unknown optional header magic"); } - uint16_t subsys; rd16(opt + 68, subsys); + uint16_t subsys = 0; + uint32_t num_rva = 0; + if (!rd16(opt + 68, subsys) || !rd32(dir_base - 4, num_rva)) + return err(info, "truncated optional header"); + if (num_rva > (opt_size - (dir_base - opt)) / 8) + return err(info, "data directories exceed optional header size"); info.subsystem = subsys; info.subsystem_name = subsystem_name(subsys); @@ -225,38 +238,34 @@ class Analyzer { info.overall_entropy = shannon_entropy(buf_.data(), buf_.size()); // ---- imports (DataDirectory[1]) ---- - uint32_t imp_rva = 0, imp_sz = 0; - rd32(dir_base + 8 * constants::DIR_IMPORT, imp_rva); - rd32(dir_base + 8 * constants::DIR_IMPORT + 4, imp_sz); + uint32_t imp_rva = 0; + if (num_rva > constants::DIR_IMPORT) + rd32(dir_base + 8 * constants::DIR_IMPORT, imp_rva); if (imp_rva != 0) parse_imports(info, imp_rva); // best-effort; never fatal build_imphash_string(info); // ---- resource directory (DataDirectory[2]) ---- - rd32(dir_base + 8 * 2, info.rsrc_rva); - rd32(dir_base + 8 * 2 + 4, info.rsrc_size); + if (num_rva > 2) { + rd32(dir_base + 8 * 2, info.rsrc_rva); + rd32(dir_base + 8 * 2 + 4, info.rsrc_size); + } // ---- Authenticode (DataDirectory[4]) - just presence, not validation ---- - uint32_t auth_sz = 0; - rd32(dir_base + 8 * 4 + 4, auth_sz); - info.has_authenticode = (auth_sz > 0); - - // how many data directories the file actually declares - uint32_t num_rva = 0; - rd32(dir_base - 4, num_rva); - - // ---- byte ranges the Authenticode digest skips ---- - info.ah_checksum_off = opt + 64; - info.ah_secdir_off = dir_base + 8 * 4; - { + if (num_rva > 4) { + // The checksum and security-directory entry are both needed to + // compute a meaningful Authenticode digest. + info.ah_checksum_off = opt + 64; + info.ah_secdir_off = dir_base + 8 * 4; uint32_t cva = 0, csz = 0; rd32(dir_base + 8 * 4, cva); // security dir VA == raw file offset rd32(dir_base + 8 * 4 + 4, csz); info.ah_cert_off = cva; info.ah_cert_size = csz; + info.has_authenticode = (csz > 0); + info.ah_ready = true; } - info.ah_ready = true; // ---- managed / .NET (dir 14) ---- if (num_rva > 14) { @@ -315,12 +324,12 @@ class Analyzer { // ---- bounds-checked little-endian readers ---- bool rd16(size_t off, uint16_t& out) const { - if (off + 2 > buf_.size()) return false; + if (off > buf_.size() || buf_.size() - off < 2) return false; out = (uint16_t)(buf_[off] | (buf_[off + 1] << 8)); return true; } bool rd32(size_t off, uint32_t& out) const { - if (off + 4 > buf_.size()) return false; + if (off > buf_.size() || buf_.size() - off < 4) return false; out = (uint32_t)buf_[off] | ((uint32_t)buf_[off + 1] << 8) | ((uint32_t)buf_[off + 2] << 16) | ((uint32_t)buf_[off + 3] << 24); return true; @@ -338,10 +347,13 @@ class Analyzer { // table. RVAs inside the header region map 1:1. bool rva_to_offset(const Info& info, uint32_t rva, size_t& off) const { for (const auto& s : info.sections) { - uint32_t span = std::max(s.virtual_size, s.raw_size); - if (rva >= s.virtual_addr && rva < s.virtual_addr + span) { - off = s.raw_ptr + (rva - s.virtual_addr); - return off < buf_.size(); + const uint64_t start = s.virtual_addr; + const uint64_t span = std::max(s.virtual_size, s.raw_size); + if (rva >= start && uint64_t(rva) - start < span) { + const uint64_t raw = uint64_t(s.raw_ptr) + (uint64_t(rva) - start); + if (raw >= buf_.size()) return false; + off = static_cast(raw); + return true; } } if (rva < buf_.size()) { off = rva; return true; } // header-resident @@ -393,7 +405,11 @@ class Analyzer { size_t desc_off; if (!rva_to_offset(info, import_rva, desc_off)) return; + // Bound total thunk work across all DLLs, not only per descriptor. + // A malformed file can otherwise repeat one table thousands of times. + size_t remaining_thunks = 32768; for (int idx = 0; idx < 4096; ++idx) { // sane upper bound + if (!remaining_thunks) break; size_t base = desc_off + (size_t)idx * 20; uint32_t oft, name_rva, first; if (!rd32(base + 0, oft) || @@ -411,14 +427,15 @@ class Analyzer { // the IAT (FirstThunk) when the ILT was stripped. uint32_t thunk_rva = oft ? oft : first; if (thunk_rva) - read_thunks(info, thunk_rva, imp); + read_thunks(info, thunk_rva, imp, remaining_thunks); info.total_imports += imp.functions.size(); info.imports.push_back(std::move(imp)); } } - void read_thunks(const Info& info, uint32_t thunk_rva, Import& imp) { + void read_thunks(const Info& info, uint32_t thunk_rva, Import& imp, + size_t& remaining_thunks) { size_t toff; if (!rva_to_offset(info, thunk_rva, toff)) return; @@ -426,7 +443,8 @@ class Analyzer { const uint64_t ord_flag = is64 ? 0x8000000000000000ULL : 0x80000000ULL; const size_t step = is64 ? 8 : 4; - for (int i = 0; i < 8192; ++i) { // sane upper bound + for (int i = 0; i < 8192 && remaining_thunks; ++i) { + --remaining_thunks; size_t entry = toff + (size_t)i * step; uint64_t val; if (is64) { if (!rd64(entry, val)) break; } diff --git a/tests/direct_smoke.ps1 b/tests/direct_smoke.ps1 index 4540367..9047472 100644 --- a/tests/direct_smoke.ps1 +++ b/tests/direct_smoke.ps1 @@ -14,10 +14,10 @@ function Require($condition, $message) { try { New-Item -ItemType Directory -Path $root | Out-Null $version = (& $Horus --version) -join '' - Require ($version -eq '4.0.0') '--version did not report 4.0.0' + Require ($version -match '^\d+\.\d+\.\d+$') '--version did not report a semantic version' $help = (& $Horus --help --no-color) -join "`n" Require ($help.Contains('--case') -and $help.Contains('--enrich') -and - $help.Contains('--no-recurse') -and $help.Contains('--gui')) 'v4 options are missing from help' + $help.Contains('--no-recurse') -and $help.Contains('--gui')) 'expected options are missing from help' $sample = Join-Path $root 'sample with spaces' Copy-Item -LiteralPath $Horus -Destination $sample @@ -32,11 +32,18 @@ try { Require (-not [string]::IsNullOrWhiteSpace($pe.tlsh) -and -not [string]::IsNullOrWhiteSpace($pe.ssdeep)) 'direct PE fuzzy hashes are missing' + $diffText = (& $Horus --diff $sample $sample --json --no-color) -join "`n" + $diffCode = $LASTEXITCODE + $diff = $diffText | ConvertFrom-Json + Require (($diffCode -eq 0) -or ($diffCode -eq 1)) 'JSON PE diff returned an invalid exit code' + Require ($diff.kind -eq 'diff' -and $diff.analysis_status -eq 'complete') 'JSON PE diff did not complete' + Require ($diff.comparison.sha256_match -eq $true -and $diff.comparison.tlsh_distance -eq 0) 'JSON PE diff lost identity evidence' + $guiReport = Join-Path $root 'drag and drop report.html' & $HorusGui $sample --html $guiReport --no-color | Out-Null Require (($LASTEXITCODE -eq 0) -or ($LASTEXITCODE -eq 1)) 'GUI drag-and-drop argument failed' Require (Test-Path -LiteralPath $guiReport) 'GUI report was not written' - Require ([IO.File]::ReadAllText($guiReport).Contains('v4.0.0')) 'GUI did not embed the central version' + Require ([IO.File]::ReadAllText($guiReport).Contains("v$version")) 'GUI did not embed the central version' $plainPath = Join-Path $root 'plain file.txt' [IO.File]::WriteAllText($plainPath, 'plain text') @@ -49,6 +56,12 @@ try { Require ($plain.md5.Length -eq 32 -and $plain.sha1.Length -eq 40 -and $plain.sha256.Length -eq 64) 'non-PE inventory did not return all hashes' + $unknownDiffText = (& $Horus --diff $sample $plainPath --json --no-color) -join "`n" + $unknownDiffCode = $LASTEXITCODE + $unknownDiff = $unknownDiffText | ConvertFrom-Json + Require ($unknownDiffCode -eq 3) 'mixed PE/non-PE diff did not return UNKNOWN exit code 3' + Require ($unknownDiff.analysis_status -eq 'unknown' -and $unknownDiff.file_b.score -eq $null) 'mixed diff invented a score or comparison' + $reportPath = Join-Path $root 'offline report.html' & $Horus $plainPath --html $reportPath --no-color | Out-Null Require ($LASTEXITCODE -eq 3) 'UNKNOWN HTML export did not return 3' @@ -63,12 +76,34 @@ try { Require ($indicator.analysis_status -eq 'unknown') 'unenriched indicator was not UNKNOWN' $ErrorActionPreference = 'Continue' + $horusCli = Join-Path (Split-Path -Parent $Horus) 'horus-cli.exe' + Require (Test-Path -LiteralPath $horusCli) 'CLI-only binary was not built' + $invalidReport = Join-Path $root 'invalid mode report.html' & $Horus --max-file-mb nope $sample --no-color 2>$null | Out-Null Require ($LASTEXITCODE -eq 2) 'malformed number did not return 2' & $Horus --scan $plainPath --no-color 2>$null | Out-Null Require ($LASTEXITCODE -eq 2) '--scan accepted a regular file' & $Horus $plainPath $sample --no-color 2>$null | Out-Null Require ($LASTEXITCODE -eq 2) 'multiple direct targets were accepted' + & $Horus --scan $root --gui 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) '--scan with --gui was accepted' + & $Horus --scan $root --html $invalidReport 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) '--scan with --html was accepted' + & $Horus --diff $sample $sample --gui 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) '--diff with --gui was accepted' + & $Horus --diff $sample $sample --html $invalidReport 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) '--diff with --html was accepted' + & $Horus $sample --gui --html $invalidReport 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) '--gui with --html was accepted' + & $HorusCli $sample --gui 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) 'CLI-only binary accepted --gui' + & $HorusCli $sample --html $invalidReport 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) 'CLI-only binary accepted --html' + & $HorusGui --scan $root 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) 'GUI-only binary accepted --scan' + & $HorusGui --diff $sample $sample 2>$null | Out-Null + Require ($LASTEXITCODE -eq 2) 'GUI-only binary accepted --diff' + Require (-not (Test-Path -LiteralPath $invalidReport)) 'invalid mode wrote an HTML report' $ErrorActionPreference = 'Stop' $large = Join-Path $root 'large.bin' diff --git a/tests/release_checks.ps1 b/tests/release_checks.ps1 index ccc8dff..b33bb64 100644 --- a/tests/release_checks.ps1 +++ b/tests/release_checks.ps1 @@ -42,6 +42,25 @@ foreach ($name in $binaries) { Require (-not $ascii.Contains('VCRUNTIME140_1.dll')) "$name depends on the Visual C++ redistributable" } +$sumsPath = Join-Path $BinDir 'SHA256SUMS.txt' +if (Test-Path -LiteralPath $sumsPath -PathType Leaf) { + $lines = @(Get-Content -LiteralPath $sumsPath) + Require ($lines.Count -eq $binaries.Count) 'SHA256SUMS.txt must list exactly three binaries' + $seen = @{} + foreach ($line in $lines) { + Require ($line -cmatch '^([0-9a-f]{64}) (horus(?:-cli|-gui)?\.exe)$') "invalid SHA256SUMS.txt line: $line" + $expectedHash = $Matches[1] + $name = $Matches[2] + Require (-not $seen.ContainsKey($name)) "duplicate SHA256SUMS.txt entry: $name" + $seen[$name] = $true + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $BinDir $name)).Hash.ToLowerInvariant() + Require ($actualHash -ceq $expectedHash) "SHA256SUMS.txt mismatch: $name" + } + foreach ($name in $binaries) { + Require ($seen.ContainsKey($name)) "SHA256SUMS.txt is missing $name" + } +} + $unifiedVersion = (& (Join-Path $BinDir 'horus.exe') --version) -join '' Require ($LASTEXITCODE -eq 0 -and $unifiedVersion -eq $ExpectedVersion) 'horus.exe --version mismatch' $cliVersion = (& (Join-Path $BinDir 'horus-cli.exe') --version) -join '' diff --git a/tests/test_case.cpp b/tests/test_case.cpp index 2a82522..008b670 100644 --- a/tests/test_case.cpp +++ b/tests/test_case.cpp @@ -29,6 +29,12 @@ static const casework::Sample* find_sample(const casework::RunResult& run, return nullptr; } +static const casework::Change* find_change(const casework::RunResult& run, + const std::string& path) { + for (const auto& change : run.changes) if (change.path == path) return &change; + return nullptr; +} + static std::string fake_sha(size_t value) { std::ostringstream out; out << std::hex << std::setfill('0') << std::setw(64) << value; @@ -67,6 +73,8 @@ int main(int argc, char** argv) { CHECK(casework::build(opts, first, error)); if (!error.empty()) std::cerr << error << "\n"; CHECK(first.state.samples.size() == 6); + CHECK(first.changes.size() == 6); + CHECK(find_change(first, "a.exe") && find_change(first, "a.exe")->kind == "added"); CHECK(first.duplicates.size() == 1); CHECK(first.duplicates[0].paths.size() == 3); CHECK(!first.clusters.empty()); @@ -77,6 +85,10 @@ int main(int argc, char** argv) { CHECK(first.public_json.find(opts.abuse_key) == std::string::npos); CHECK(first.public_json.find("malware_family") == std::string::npos); CHECK(json::parse(first.public_json)["schema"].as_string() == "horus-case/v1"); + CHECK(json::parse(first.public_json)["changes"].items().size() == 6); + CHECK(json::parse(first.public_json)["summary"]["changes"].as_int() == 6); + CHECK(read_text(workspace / "changes.csv").find("added,a.exe") != std::string::npos); + CHECK(read_text(workspace / "changes.csv").find("'=formula.txt") != std::string::npos); const std::string payload = ""; std::string report = read_text(workspace / "report.html"); @@ -132,6 +144,8 @@ int main(int argc, char** argv) { CHECK(casework::build(opts, second, error)); CHECK(second.state.id == case_id); CHECK(second.reused == 6); + CHECK(second.changes.empty()); + CHECK(json::parse(second.public_json)["changes"].items().empty()); CHECK(!second.clusters.empty() && second.clusters[0].id == cluster_id); fs::path moved_workspace = root / "workspace-moved"; @@ -155,6 +169,8 @@ int main(int argc, char** argv) { CHECK(casework::build(opts, added, error)); CHECK(find_sample(added, "new sample.txt") != nullptr); CHECK(added.analyzed >= 1); + CHECK(find_change(added, "new sample.txt") && + find_change(added, "new sample.txt")->kind == "added"); core::Options rebuild = opts; rebuild.rebuild = true; @@ -167,6 +183,8 @@ int main(int argc, char** argv) { casework::RunResult changed; CHECK(casework::build(opts, changed, error)); CHECK(changed.analyzed >= 1); + CHECK(find_change(changed, "text sample.txt") && + find_change(changed, "text sample.txt")->kind == "modified"); fs::remove(samples / "text sample.txt", ec); CHECK(!ec); @@ -174,12 +192,42 @@ int main(int argc, char** argv) { CHECK(casework::build(opts, missing, error)); CHECK(find_sample(missing, "text sample.txt") != nullptr); CHECK(find_sample(missing, "text sample.txt")->missing); + CHECK(find_change(missing, "text sample.txt") && + find_change(missing, "text sample.txt")->kind == "missing"); core::Options prune = opts; prune.prune = true; casework::RunResult pruned; CHECK(casework::build(prune, pruned, error)); CHECK(find_sample(pruned, "text sample.txt") == nullptr); + CHECK(find_change(pruned, "text sample.txt") && + find_change(pruned, "text sample.txt")->kind == "removed"); + + casework::State before_changes, after_changes; + casework::Sample restored; + restored.path = "restored.exe"; + restored.missing = true; + restored.sha256 = fake_sha(23); + before_changes.samples.push_back(restored); + restored.missing = false; + restored.status = "complete"; + after_changes.samples.push_back(restored); + casework::Sample reassessed; + reassessed.path = "reassessed.exe"; + reassessed.sha256 = fake_sha(24); + reassessed.status = "complete"; + reassessed.has_score = true; + reassessed.score = 10; + reassessed.verdict = "LOW"; + before_changes.samples.push_back(reassessed); + reassessed.score = 40; + reassessed.verdict = "SUSPICIOUS"; + after_changes.samples.push_back(reassessed); + auto changes = casework::detect_changes(before_changes, after_changes); + CHECK(changes.size() == 2); + CHECK(changes[0].kind == "assessment_changed"); + CHECK(changes[0].before_score == 10 && changes[0].after_score == 40); + CHECK(changes[1].kind == "restored"); fs::create_directories(samples / "nested", ec); CHECK(!ec); diff --git a/tests/test_diff.cpp b/tests/test_diff.cpp index f451065..0e3271d 100644 --- a/tests/test_diff.cpp +++ b/tests/test_diff.cpp @@ -1,6 +1,12 @@ #include "core.hpp" +#include "diff.hpp" #include "fuzzy.hpp" +#include "json.hpp" +#include +#include +#include #include +#include using namespace horus; int failures = 0; @@ -20,6 +26,66 @@ int main(int argc, char** argv){ // sanity: tlsh_diff is symmetric and non-negative CHECK(fuzzy::tlsh_diff(a.tlsh, b.tlsh) == fuzzy::tlsh_diff(b.tlsh, a.tlsh)); + core::Options opts; + opts.json = true; + auto capture_diff = [&](const std::string& left, const std::string& right) { + std::ostringstream captured; + std::streambuf* original = std::cout.rdbuf(captured.rdbuf()); + int code = diffs::run(opts, left, right); + std::cout.rdbuf(original); + return std::make_pair(code, captured.str()); + }; + + auto [same_code, same_text] = capture_diff(argv[0], argv[0]); + auto same = json::parse(same_text); + CHECK(same_code == (a.final_score >= 30 ? 1 : 0)); + CHECK(same["kind"].as_string() == "diff"); + CHECK(same["analysis_status"].as_string() == "complete"); + CHECK(same["comparison"]["sha256_match"].as_bool()); + CHECK(a.imphash.empty() ? same["comparison"]["imphash_match"].is_null() + : same["comparison"]["imphash_match"].as_bool()); + CHECK(same["comparison"]["tlsh_distance"].as_int(-1) == 0); + CHECK(same["comparison"]["sections"].is_array()); + CHECK(same["file_a"]["path"].as_string() == argv[0]); + CHECK(same["file_b"]["score"].is_number()); + + namespace fs = std::filesystem; + fs::path plain = fs::temp_directory_path() / + ("horus-diff-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".txt"); + { + std::ofstream output(plain, std::ios::binary); + output << "plain text"; + } + auto [unknown_code, unknown_text] = capture_diff(plain.string(), plain.string()); + auto unknown = json::parse(unknown_text); + CHECK(unknown_code == 3); + CHECK(unknown["analysis_status"].as_string() == "unknown"); + CHECK(unknown["file_a"]["verdict"].as_string() == "UNKNOWN"); + CHECK(unknown["file_b"]["score"].is_null()); + CHECK(unknown["comparison"]["sha256_match"].as_bool()); + CHECK(unknown["comparison"]["imphash_match"].is_null()); + CHECK(unknown["comparison"]["tlsh_distance"].is_null()); + CHECK(unknown["comparison"]["sections"].size() == 0); + + auto [mixed_code, mixed_text] = capture_diff(argv[0], plain.string()); + auto mixed = json::parse(mixed_text); + CHECK(mixed_code == 3); + CHECK(mixed["analysis_status"].as_string() == "unknown"); + CHECK(mixed["file_a"]["is_pe"].as_bool()); + CHECK(!mixed["file_b"]["is_pe"].as_bool()); + + opts.json = false; + auto [plain_code, plain_text] = capture_diff(plain.string(), plain.string()); + CHECK(plain_code == 3); + CHECK(plain_text.find("UNKNOWN") != std::string::npos); + CHECK(plain_text.find("CLEAN") == std::string::npos); + + core::FileReport skipped; + skipped.analysis_status = core::AnalysisStatus::Skipped; + CHECK(diffs::comparison_status(a, skipped) == core::AnalysisStatus::Skipped); + CHECK(diffs::comparison_exit_code(a, skipped) == 3); + fs::remove(plain); + std::cout << (failures ? "test_diff FAILED\n" : "test_diff ok\n"); return failures ? 1 : 0; } diff --git a/tests/test_pe.cpp b/tests/test_pe.cpp index 12b90bc..6af9d38 100644 --- a/tests/test_pe.cpp +++ b/tests/test_pe.cpp @@ -12,6 +12,30 @@ static std::vector slurp(const std::string& p) { std::istreambuf_iterator()); } +static void put16(std::vector& b, size_t at, uint16_t value) { + b[at] = static_cast(value); + b[at + 1] = static_cast(value >> 8); +} + +static void put32(std::vector& b, size_t at, uint32_t value) { + for (size_t i = 0; i < 4; ++i) + b[at + i] = static_cast(value >> (i * 8)); +} + +static std::vector make_pe64(uint16_t optional_size, uint32_t directories, + uint16_t sections = 0) { + std::vector b(0x400, 0); + put16(b, 0, constants::DOS_MAGIC); + put32(b, 0x3c, 0x80); + put32(b, 0x80, constants::NT_MAGIC); + put16(b, 0x84, 0x8664); // IMAGE_FILE_HEADER.Machine + put16(b, 0x86, sections); + put16(b, 0x94, optional_size); + put16(b, 0x98, constants::OPT_MAGIC_PE64); + put32(b, 0x98 + 108, directories); // NumberOfRvaAndSizes + return b; +} + int failures = 0; #define CHECK(cond) do { if(!(cond)){ std::cerr<<"FAIL line "<<__LINE__<<": " #cond "\n"; ++failures; } } while(0) @@ -59,6 +83,79 @@ int main(int argc, char** argv) { // negative test - empty CHECK(!Analyzer(std::vector{}).analyze().valid); + // A declared optional header of only two bytes must not cause the parser + // to read later bytes as fields or use uninitialized values. + auto short_optional = make_pe64(2, 0); + CHECK(!Analyzer(short_optional).analyze().valid); + auto short_pe32 = short_optional; + put16(short_pe32, 0x98, constants::OPT_MAGIC_PE32); + CHECK(!Analyzer(short_pe32).analyze().valid); + + // A directory count cannot extend beyond SizeOfOptionalHeader, even when + // the file buffer itself contains bytes at those offsets. + auto short_directories = make_pe64(112, 1); + CHECK(!Analyzer(short_directories).analyze().valid); + auto short_pe32_directories = make_pe64(96, 0); + put16(short_pe32_directories, 0x98, constants::OPT_MAGIC_PE32); + put32(short_pe32_directories, 0x98 + 92, 1); + CHECK(!Analyzer(short_pe32_directories).analyze().valid); + + // A PE with no security directory has no safe Authenticode hash range. + auto no_security_directory = make_pe64(120, 1); + Info no_security = Analyzer(no_security_directory).analyze(); + CHECK(no_security.valid && !no_security.ah_ready); + + // A malicious raw pointer plus RVA delta must not wrap around to a fake + // import descriptor in the DOS header. + auto raw_overflow = make_pe64(128, 2, 1); + put32(raw_overflow, 0x98 + 112 + 8, 0x1020); // import directory RVA + const size_t section = 0x98 + 128; + put32(raw_overflow, section + 8, 0x80); // VirtualSize + put32(raw_overflow, section + 12, 0x1000); // VirtualAddress + put32(raw_overflow, section + 16, 0x80); // SizeOfRawData + put32(raw_overflow, section + 20, 0xfffffff0u); + put32(raw_overflow, 0x10 + 12, 0x40); // fake descriptor name RVA + const char* fake_name = "fake.dll"; + for (size_t i = 0; fake_name[i]; ++i) raw_overflow[0x40 + i] = fake_name[i]; + Info overflow = Analyzer(raw_overflow).analyze(); + CHECK(overflow.valid && overflow.imports.empty()); + + // An RVA above 0xfffffff0 can still map to a section if the range check + // is performed without 32-bit addition overflow. + auto high_rva = make_pe64(128, 2, 1); + put32(high_rva, 0x98 + 112 + 8, 0xfffffff5u); + put32(high_rva, section + 8, 0x80); + put32(high_rva, section + 12, 0xfffffff0u); + put32(high_rva, section + 16, 0x80); + put32(high_rva, section + 20, 0x200); + put32(high_rva, 0x205 + 12, 0x40); + for (size_t i = 0; fake_name[i]; ++i) high_rva[0x40 + i] = fake_name[i]; + Info high = Analyzer(high_rva).analyze(); + CHECK(high.valid && high.imports.size() == 1 && + high.imports[0].dll == "fake.dll"); + + // Repeated descriptors that point to the same long thunk table must have + // a shared work limit, instead of multiplying the per-DLL limit. + auto repeated_imports = make_pe64(128, 2, 1); + repeated_imports.resize(0x30000, 0); + put32(repeated_imports, 0x98 + 112 + 8, 0x1100); + put32(repeated_imports, section + 8, 0x20000); + put32(repeated_imports, section + 12, 0x1000); + put32(repeated_imports, section + 16, 0x20000); + put32(repeated_imports, section + 20, 0x200); + for (size_t i = 0; i < 5; ++i) { + put32(repeated_imports, 0x300 + i * 20, 0x2000); // thunk RVA + put32(repeated_imports, 0x300 + i * 20 + 12, 0x40); // DLL name RVA + } + for (size_t i = 0; i < 8192; ++i) { + put32(repeated_imports, 0x1200 + i * 8, 1); + put32(repeated_imports, 0x1200 + i * 8 + 4, 0x80000000u); + } + for (size_t i = 0; fake_name[i]; ++i) repeated_imports[0x40 + i] = fake_name[i]; + Info bounded = Analyzer(repeated_imports).analyze(); + CHECK(bounded.valid && bounded.total_imports > 0 && + bounded.total_imports <= 32768 && bounded.imports.size() <= 4); + std::cout << "\n" << (failures ? std::to_string(failures)+" PE test(s) failed" : "ALL PE TESTS PASSED") << "\n"; return failures;