From 6073f040cca93e3c56ba046d7e76d22cf1987822 Mon Sep 17 00:00:00 2001 From: Xpert <115935628+XpertLambda@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:36:23 +0100 Subject: [PATCH 1/3] Engine accuracy (M0): scoring v2, stable unique finding IDs, patch table, OS role - Rating is the worst effective severity tier and the score stays inside that tier's band; 1.8.0 summed counts and reported "Critical, 100" for a host with no Critical finding. Low-confidence findings count one tier lower. - Patch hints apply only to the builds that existed before each fix and only to the right role (Zerologon on domain controllers); one low-confidence finding per CVE. Windows 11 25H2 no longer gets six 2020-2022 findings. - Finding IDs: rule + instance_key, unique per scan (engine safety net), no machine name in event-log IDs, non-ASCII encoded instead of dropped; 1.8.0 baselines still compare. - OS name from product type (Server 2025 is not Windows 11); system_info gains os_name/os_family/os_build/os_ubr. - The same fact reported by two modules (UAC disabled) is merged. - ISO-8601 UTC time and schema_version in the JSON report. - Tests: scoring (reference values + property test), identity, patch table, OS classification, timestamps; Windows smoke assertions. --- CHANGELOG.md | 52 +++++++ CMakeLists.txt | 3 + README.md | 11 +- include/core/findings.h | 49 +++++-- include/core/scoring.h | 64 +++++++++ include/core/utils.h | 50 ++++++- .../modules/eventlogs/event_log_analyzer.h | 6 +- include/modules/privesc/privesc_analyzer.h | 5 + include/modules/reporting/report_generator.h | 14 +- include/sysrecon.h | 22 +++ modules/eventlogs/event_log_analyzer.cpp | 25 +++- modules/privesc/privesc_analyzer.cpp | 37 +++-- modules/registry/registry_analyzer.cpp | 3 + modules/reporting/report_generator.cpp | 67 ++++----- src/core/engine.cpp | 33 +++++ src/core/findings.cpp | 118 +++++++++++++++- src/core/scoring.cpp | 100 ++++++++++++++ src/core/utils.cpp | 67 ++++----- src/core/utils_common.cpp | 129 +++++++++++++++--- tests/test_findings.cpp | 128 ++++++++++++++++- tests/test_scoring.cpp | 124 +++++++++++++++++ tests/test_utils_crypto.cpp | 102 +++++++++++++- tests/windows/smoke-test.ps1 | 36 +++++ 23 files changed, 1120 insertions(+), 125 deletions(-) create mode 100644 include/core/scoring.h create mode 100644 src/core/scoring.cpp create mode 100644 tests/test_scoring.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abe45f..13b6d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,58 @@ All notable changes to SysRecon are documented here. ## [Unreleased] +Engine accuracy release (milestone M0). Findings, scores and baselines change as +described below; reports gain fields but lose none. + +### Fixed + +- **Overall rating contradicted the findings.** 1.8.0 summed + `critical*25 + high*10 + medium*4 + low` and capped at 100, so a Windows 11 + host with no Critical and seven High findings printed "score 100, rating + Critical". The rating is now the worst *effective* severity tier present and + the score stays inside that tier's band (Critical 85-100, High 60-84, + Moderate 30-59, Low 5-29, Minimal 0); repeats of one rule count less; a + low-confidence finding counts one tier lower. `summary.scoring` in the JSON + names the model (`model_version: 2`). **Scripts that key on `rating` or + `risk_score` will see different values.** +- **"Potential missing LPE patch" on current Windows.** The CVE table applied to + every build, so Windows 11 25H2 (26200) got six findings about 2020-2022 + vulnerabilities. Each CVE now lists the builds that existed before its fix and + is only a candidate there (Windows 11, Server 2022+ and Windows 10 21H2+ no + longer match the 2021 issues). Zerologon (CVE-2020-1472) is a domain-controller + flaw and is reported only on domain controllers. The findings are low + confidence ("verify"), one per CVE. +- **Finding IDs were not unique or stable.** Six patch findings and two + credential-artefact findings shared one ID, and event-log IDs contained the + machine name, so renaming or re-imaging a host changed them. A rule that can + fire several times now gives each finding an instance part + (`privilege-escalation/potential-missing-lpe-patch#cve-2021-34527`, + `event-logs/account-created#vagrant`), and the engine renames any remaining + duplicate deterministically and logs a warning. Non-ASCII names are encoded + (`u7528-u6237`) instead of being dropped, so they no longer collide. + `--baseline` still matches 1.8.0 reports: an instanced finding matches a 1.8.0 + entry for the same rule. Event-log account findings have new IDs, so an old + baseline shows them once as resolved plus new. +- **Windows Server was reported as "Windows 10/11"** (Server 2025 and Windows 11 + 24H2 are both build 26100). The OS name now uses the product type; the JSON + gains `system_info.os_name`, `os_family` (`client`, `server`, + `domain_controller`), `os_build` and `os_ubr`. +- **UAC-disabled was reported twice** (Registry and Privilege Escalation). Two + modules reporting the same underlying value are now merged to one finding, the + one with remediation. +- **Ambiguous time.** `scan_info.generated_utc` (ISO-8601 UTC) and + `utc_offset_minutes` are added next to the zoneless `timestamp`, which is kept + for file names and older tooling. `scan_info.schema_version` (1) is new. + +### Added + +- Native unit tests for scoring (reference values, a property test over random + finding sets), identity (instances, uniqueness, Unicode, 1.8 baselines), the + patch table (modern builds, build ranges, role), OS classification and ISO + timestamps. The Windows smoke test asserts unique IDs, rating/score + consistency, the detected host role, no stale patch findings on modern builds + and a single UAC finding. + ## [1.8.0] — 2026-11-02 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 139ed11..d60342e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ add_library(sysrecon_core STATIC src/core/config.cpp src/core/scan_plan.cpp src/core/findings.cpp + src/core/scoring.cpp ) target_include_directories(sysrecon_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include @@ -96,6 +97,7 @@ set(HEADERS include/core/text_utils.h include/core/scan_plan.h include/core/findings.h + include/core/scoring.h include/core/whitelist.h include/modules/accounts/user_enum.h include/modules/services/service_enum.h @@ -172,6 +174,7 @@ if(SYSRECON_BUILD_TESTS AND NOT CMAKE_CROSSCOMPILING) tests/test_config.cpp tests/test_utils_crypto.cpp tests/test_findings.cpp + tests/test_scoring.cpp ) target_link_libraries(sysrecon_tests PRIVATE sysrecon_core GTest::gtest_main) # Lets tests validate the config.json that actually ships. diff --git a/README.md b/README.md index 3f72e1d..7eadd11 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,14 @@ can make — it never authenticates elsewhere or modifies the directory: - **Medium**: Security weaknesses requiring attention within planned maintenance windows - **Low**: Best practice deviations and informational findings +**Overall rating and score.** The rating is the worst severity tier present, never +a sum, and the 0-100 score stays inside that tier's band, so the two always +agree: Critical 85-100, High 60-84, Moderate 30-59, Low 5-29, Minimal 0. Many +Medium or Low findings can raise the score within their band but never turn the +rating into High or Critical. A finding from a heuristic check (low confidence, +for example "a KB for this old CVE is not in the hotfix list") counts one tier +lower. The JSON report records the model under `summary.scoring`. + #### Executive Intelligence - High-level security posture summaries @@ -752,7 +760,8 @@ sysrecon.exe --privesc --min-severity high --exit-code -f sarif -o .\out } } ], - "summary": { "critical": 0, "high": 3, "medium": 9, "low": 25, "total": 37 }, + "summary": { "critical": 0, "high": 3, "medium": 9, "low": 25, "total": 37, + "risk_score": 79, "rating": "High", "scoring": { "model_version": 2 } }, "incomplete_checks": [] } ``` diff --git a/include/core/findings.h b/include/core/findings.h index bf092c5..d0d2ada 100644 --- a/include/core/findings.h +++ b/include/core/findings.h @@ -10,18 +10,48 @@ namespace SysRecon { namespace Core { namespace Findings { -// Lower-case ASCII slug: letters and digits are kept, every other run of -// characters becomes a single '-', with no leading or trailing '-'. -// "Privilege Escalation" -> "privilege-escalation". +// Lower-case ASCII slug: ASCII letters and digits are kept, every other run of +// punctuation/whitespace becomes a single '-', with no leading or trailing +// '-'. "Privilege Escalation" -> "privilege-escalation". +// A non-ASCII character is never discarded (that made distinct names collide, +// and an all-non-ASCII name vanish): it becomes its own token "uXXXX" (code +// point in lower-case hex, at least four digits), so "Benutzer \u00fcber" -> +// "benutzer-u00fc-ber". Identical on every platform (UTF-16 surrogate pairs are +// combined into one code point first). String Slug(const String& text); -// Stable identifier of a finding: "/", e.g. -// "privilege-escalation/writable-directory-on-path-c-tools". It depends only -// on the module and item names, never on the description or details, so -// counts and timestamps in a description do not change it between runs. +// Stable identifier of a finding. Findings are identified by the rule that +// produced them plus, where one rule can fire several times, the object it +// fired on: +// "/" one instance per scan +// "/#" several instances +// The check part is ScanResult::check_id when set, else the item name; the +// instance part is ScanResult::instance_key. It depends on nothing else, so +// counts, timestamps and descriptions never change it between runs, and rules +// that supply an instance_key never put the machine's own name in the ID. +// Findings without check_id/instance_key keep the 1.8.0 ID +// ("/"), so 1.8 baselines still compare. String FindingId(const String& module_name, const String& item_name); String FindingId(const ScanResult& result); +// The rule part of the ID: "/" (no instance). Scoring +// uses it to down-weight repeats of one rule. +String CheckId(const ScanResult& result); + +// Makes every ID in `results` unique, deterministically (by order). The second +// and later finding with an ID already seen gets "dupN" appended to its +// instance_key. A rule that fires several times should set instance_key +// itself; this is the safety net, and the number returned (how many findings +// had to be renamed) lets the engine log a warning and tests assert zero. +size_t EnsureUniqueIds(ScanResults& results); + +// Collapses findings that report the same underlying fact (equal, non-empty +// fact_key) to one, so a value read by two modules is not counted twice. Keeps +// the most useful of each group (one that carries remediation, then the +// earliest), preserves the order of the survivors, and returns how many were +// dropped. +size_t DropDuplicateFacts(ScanResults& results); + // Case-insensitive glob match where '*' matches any run of characters // (including '/') and '?' matches exactly one. bool GlobMatch(const String& pattern, const String& text); @@ -71,7 +101,10 @@ struct BaselineDiff { // Compares by finding ID as a multiset: if the baseline had two findings with // one ID and the current scan has three, one of the three is new; if it has -// one, one baseline entry is resolved. +// one, one baseline entry is resolved. A current finding whose ID has an +// instance part ("rule#instance") that the baseline does not know also matches +// a baseline entry carrying just the rule ID, because reports before 1.9 +// identified every instance of a rule by the rule alone. BaselineDiff Compare(const ScanResults& current, const Baseline& baseline); // The detail key under which the engine records "new" / "unchanged". diff --git a/include/core/scoring.h b/include/core/scoring.h new file mode 100644 index 0000000..04a36fb --- /dev/null +++ b/include/core/scoring.h @@ -0,0 +1,64 @@ +#pragma once + +#include "../sysrecon.h" + +// Host risk score and rating (scoring model v2). +// +// v1 (<= 1.8.0) added critical*25 + high*10 + medium*4 + low and capped the sum +// at 100, so thirty Medium findings alone produced "score 100, rating Critical" +// with no Critical or High finding. v2 makes the rating the *worst effective +// tier* and keeps the score inside that tier's band, so the two can never +// disagree and a pile of lower-tier findings can never reach a higher tier. +// +// effective tier = severity, demoted one tier when confidence is Low +// (Critical->High->Medium->Low; Low stays Low), so a +// heuristic cannot drive a red headline. +// T = worst effective tier present. +// q_i = k[tier_i] * confidence_factor_i * w_i +// w_i = 1 for the first finding of a rule (CheckId), +// kRepeatWeight for repeats of the same rule. +// sat = 1 - prod(1 - q_i) (over all findings) +// score = round_half_up(floor[T] + (cap[T] - floor[T]) * sat) +// +// tier floor cap k rating +// Critical 85 100 0.500 Critical +// High 60 84 0.200 High +// Medium 30 59 0.060 Moderate +// Low 5 29 0.015 Low +// (nothing counts) 0 Minimal +// +// Pure arithmetic with no Win32 dependency; covered by tests/test_scoring.cpp. + +namespace SysRecon { +namespace Core { +namespace Scoring { + +// Bumped whenever the formula or its constants change; written to the report +// so a consumer can tell which model produced a score. +constexpr int kModelVersion = 2; +constexpr double kRepeatWeight = 0.3; + +struct Entry { + SecurityLevel severity = SecurityLevel::Low; + Confidence confidence = Confidence::High; + String check_id; // Repeats of one rule are down-weighted. +}; + +struct Result { + int score = 0; // 0-100 + String rating; // Critical | High | Moderate | Low | Minimal + size_t counted = 0; // Findings that contributed. +}; + +// The severity a finding counts as after the confidence demotion. +SecurityLevel EffectiveTier(SecurityLevel severity, Confidence confidence); + +Result Compute(const std::vector& entries); +Result Compute(const ScanResults& results); + +// Rating label for a score band (kept for callers that only have the score). +String RatingForTier(SecurityLevel tier); + +} +} +} diff --git a/include/core/utils.h b/include/core/utils.h index 986a6fa..98ede74 100644 --- a/include/core/utils.h +++ b/include/core/utils.h @@ -41,12 +41,42 @@ String ExtractExecutablePath(const String& image_path); String Sha256Hex(const ByteVector& data); String CalculateSHA256(const String& file_path); +// What kind of Windows this is. Build numbers alone cannot tell client from +// server (Windows Server 2025 and Windows 11 24H2 are both build 26100). +enum class OsFamily { Unknown, Client, Server, DomainController }; + +struct OsClassification { + OsFamily family = OsFamily::Unknown; + String name; // "Windows 11", "Windows Server 2025", "Windows Server 2012 R2", ... +}; + +// Pure mapping from RtlGetVersion's fields to a family and a product name. +// `product_type` is OSVERSIONINFOEX::wProductType: 1 workstation, 2 domain +// controller, 3 server (0 = unknown, treated as a client). Unit-tested. +OsClassification ClassifyWindows(uint32_t major, uint32_t minor, uint32_t build, uint32_t product_type); + +// One curated local-privilege-escalation issue whose fix looks to be missing. +struct LpePatchHint { + String cve; // "CVE-2021-34527" + String name; // "PrintNightmare (Print Spooler RCE/LPE)" + String fix_kbs; // "KB5004945, KB5004946, ..." for the evidence line + String message; // " () - no fixing hotfix found ()" +}; + // Offline "is a known local-privilege-escalation patch missing?" lookup. -// Given the OS build number and the set of installed hotfix IDs (e.g. -// "KB5004945"), returns one human-readable hint per curated CVE whose fixing -// KB is not present. Pure data + string logic, unit-tested. The Win32 side only -// gathers the inputs (WMI Win32_QuickFixEngineering + the build number). -StringVector MissingLpePatchHints(uint32_t os_build, const std::set& installed_kbs); +// Given the OS build number, the set of installed hotfix IDs (e.g. +// "KB5004945") and whether the host is a domain controller, returns one hint +// per curated CVE that (a) can affect this build, (b) applies to this role and +// (c) whose fixing KB is not present. A CVE is only a candidate on the builds +// that existed before its fix; later builds (Windows 11, Server 2022+, Windows +// 10 21H2+ for the 2021 issues) shipped with it and are never reported, which +// is what stops a current Windows 11 from getting 2020-2022 "missing patch" +// findings. A hint is still a heuristic: cumulative updates supersede the +// listed KBs. Pure data + logic, unit-tested; the Win32 side only gathers the +// inputs (WMI Win32_QuickFixEngineering, RtlGetVersion, ProductType). +std::vector MissingLpePatchHints(uint32_t os_build, + const std::set& installed_kbs, + bool is_domain_controller = false); bool FileExists(const String& path); bool DirectoryExists(const String& path); @@ -71,6 +101,11 @@ String MacAddressToString(const uint8_t* mac); bool ToLocalTime(std::time_t t, std::tm& out); String FormatTimestamp(const std::chrono::system_clock::time_point& time, const String& format = L"%Y-%m-%d %H:%M:%S"); String GetCurrentTimestamp(); +// "2026-11-08T16:54:37Z": unambiguous, sortable, for machine-readable output. +// (GetCurrentTimestamp() is local time with no zone and is meant for file names.) +String FormatIso8601Utc(const std::chrono::system_clock::time_point& time); +// Offset of the local time zone from UTC at `time`, in minutes (e.g. -480). +int LocalUtcOffsetMinutes(const std::chrono::system_clock::time_point& time); class Timer { public: @@ -171,6 +206,11 @@ StringVector GetTokenGroupNames(); // resolved group account names // and the OS build number. Used together with MissingLpePatchHints(). std::set GetInstalledHotfixes(); uint32_t GetOsBuildNumber(); +// Family and name from the live OS (RtlGetVersion incl. product type). +OsClassification GetOsClassification(); +// Update Build Revision (the ".7019" in 26200.7019): which cumulative update +// is installed. 0 when it cannot be read. +uint32_t GetOsUbr(); #endif } diff --git a/include/modules/eventlogs/event_log_analyzer.h b/include/modules/eventlogs/event_log_analyzer.h index e5d2bc9..f76d6dd 100644 --- a/include/modules/eventlogs/event_log_analyzer.h +++ b/include/modules/eventlogs/event_log_analyzer.h @@ -48,9 +48,13 @@ class EventLogAnalyzer { String WindowLabel() const; bool ReportQueryFailure(const String& check, const String& channel, QueryStatus status); + // `check_id` / `instance_key` give the finding a stable identity when the + // item text varies per host or account (see ScanResult): the ID must not + // contain the machine's own name. void Record(const String& item, const String& description, SecurityLevel risk, const StringMap& details, const String& remediation = L"", - const String& reference = L""); + const String& reference = L"", const String& check_id = L"", + const String& instance_key = L""); ScanResults scan_results_; size_t lookback_days_ = 7; diff --git a/include/modules/privesc/privesc_analyzer.h b/include/modules/privesc/privesc_analyzer.h index 9e55132..368de14 100644 --- a/include/modules/privesc/privesc_analyzer.h +++ b/include/modules/privesc/privesc_analyzer.h @@ -17,6 +17,11 @@ struct PrivEscFinding { String remediation; // How to close the gap. String reference; // MITRE ATT&CK ID / CVE / docs link. SecurityLevel risk_level = SecurityLevel::Medium; + // Identity (see ScanResult): set instance_key whenever one check can fire + // more than once, e.g. a CVE id or a privilege name; never a hostname. + String instance_key; + Confidence confidence = Confidence::High; + String fact_key; // Same fact reported by another module (see ScanResult). }; // PrivEscAnalyzer performs read-only, host-local checks for common Windows diff --git a/include/modules/reporting/report_generator.h b/include/modules/reporting/report_generator.h index 3a068da..6fcf335 100644 --- a/include/modules/reporting/report_generator.h +++ b/include/modules/reporting/report_generator.h @@ -2,6 +2,7 @@ #include "../../sysrecon.h" #include "../../core/findings.h" +#include "../../core/scoring.h" namespace SysRecon { namespace Modules { @@ -26,8 +27,11 @@ struct ReportConfig { struct SystemInfo { String computer_name; String domain_name; - String os_version; + String os_version; // Display string, e.g. "Windows 11 (Build 26200)". + String os_name; // "Windows 11", "Windows Server 2025", ... + String os_family; // client | server | domain_controller | unknown String os_build; + uint32_t os_ubr = 0; // Update Build Revision (26200.); 0 = unknown. String architecture; String processor; String memory_total; @@ -81,10 +85,10 @@ class ReportGenerator { bool GenerateSARIF(const String& filename); // SARIF 2.1.0 bool GenerateAttackNavigatorLayer(const String& filename); // MITRE ATT&CK Navigator layer - // Weighted 0-100 risk score (critical*25 + high*10 + medium*4 + low*1, - // capped) and its rating label. Shared by the summary of every format. - int CalculateRiskScore() const; - String RiskRating(int score) const; + // Host risk score (0-100) and rating, scoring model v2 (see core/scoring.h): + // the rating is the worst effective severity tier present and the score + // stays inside that tier's band. Shared by the summary of every format. + Core::Scoring::Result RiskAssessment() const; private: ReportConfig config_; diff --git a/include/sysrecon.h b/include/sysrecon.h index 49db943..bcd656d 100644 --- a/include/sysrecon.h +++ b/include/sysrecon.h @@ -66,6 +66,15 @@ namespace SysRecon { Critical = 3 }; + // How sure a check is that its finding is true and not expected. Scoring + // demotes a Low-confidence finding by one severity tier so a heuristic can + // never drive the headline rating (see core/scoring.h). + enum class Confidence { + Low = 0, // Heuristic: name-based, assumed default, or unverified. + Medium = 1, // Inferred from policy / registry configuration. + High = 2 // Effective state read directly from an authoritative API. + }; + // Module types enum class ModuleType { Accounts, @@ -90,6 +99,19 @@ namespace SysRecon { // Phase 2: actionable output for pen-testers / defenders. String remediation; // How to fix / harden the finding. String reference; // MITRE ATT&CK technique, CVE, or doc link. + // Stable identity (see core/findings.h). `check_id` names the rule and + // must not vary per instance ("Account created", not "Account created: + // HOST\\user"); empty means "derive it from item_name". `instance_key` + // names the object the rule fired on (a CVE, a SID, a service short + // name) and must never contain this machine's own name. Two findings + // from one rule MUST differ in instance_key. + String check_id; + String instance_key; + Confidence confidence = Confidence::High; + // The underlying fact, when two modules can report the same one (e.g. + // "registry:HKLM\\...\\System!EnableLUA=0"). Findings sharing a fact_key + // are collapsed to one by Findings::DropDuplicateFacts. Empty = never merged. + String fact_key; std::chrono::system_clock::time_point timestamp; }; diff --git a/modules/eventlogs/event_log_analyzer.cpp b/modules/eventlogs/event_log_analyzer.cpp index fdc5638..eabdeb7 100644 --- a/modules/eventlogs/event_log_analyzer.cpp +++ b/modules/eventlogs/event_log_analyzer.cpp @@ -14,6 +14,14 @@ namespace Modules { namespace Text = Core::Text; +namespace { +// "DOMAIN\\user" -> "user"; a bare name is returned as is. +SysRecon::String AccountNameWithoutDomain(const SysRecon::String& account) { + const size_t slash = account.rfind(L'\\'); + return slash == SysRecon::String::npos ? account : account.substr(slash + 1); +} +} // namespace + namespace { constexpr const wchar_t* kModule = L"Event Logs"; @@ -75,10 +83,13 @@ void EventLogAnalyzer::Cleanup() { void EventLogAnalyzer::Record(const String& item, const String& description, SecurityLevel risk, const StringMap& details, const String& remediation, - const String& reference) { + const String& reference, const String& check_id, + const String& instance_key) { ScanResult r; r.module_name = kModule; r.item_name = item; + r.check_id = check_id; + r.instance_key = instance_key; r.description = description; r.risk_level = risk; r.details = details; @@ -475,7 +486,11 @@ bool EventLogAnalyzer::CheckAccountChanges() { SecurityLevel::Medium, d, L"Confirm the account was created by an authorised administrator for a known purpose; " L"disable it otherwise.", - L"T1136.001"); + L"T1136.001", + // Identity: the rule plus the account name without its domain, which for a + // local account is the name of the machine it was created on (a hostname + // changes on rename / re-image and would change the ID with it). + L"Account created", AccountNameWithoutDomain(account)); } for (const auto& [key, c] : added) { StringMap d = c.extra; @@ -491,7 +506,11 @@ bool EventLogAnalyzer::CheckAccountChanges() { SecurityLevel::High, d, L"Confirm the membership change was authorised; remove the member otherwise and review " L"what the account did since.", - L"T1098"); + L"T1098", + // The group's SID is the same on every host for built-in groups + // (S-1-5-32-544 is Administrators); the member is its bare name. + L"Added to privileged group", + c.extra.at(L"Group SID") + L"-" + AccountNameWithoutDomain(member)); } if (truncated) { SYSRECON_LOG_WARNING(L"Account-change check: more than 1000 events; only the newest were reviewed"); diff --git a/modules/privesc/privesc_analyzer.cpp b/modules/privesc/privesc_analyzer.cpp index 071e206..f9c8145 100644 --- a/modules/privesc/privesc_analyzer.cpp +++ b/modules/privesc/privesc_analyzer.cpp @@ -36,6 +36,14 @@ void PrivEscAnalyzer::Record(const PrivEscFinding& finding) { result.risk_level = finding.risk_level; result.remediation = finding.remediation; result.reference = finding.reference; + result.instance_key = finding.instance_key; + result.confidence = finding.confidence; + result.fact_key = finding.fact_key; + if (!finding.instance_key.empty()) { + // The title of an instanced rule is the rule's name; the instance goes + // in the key (and in the evidence), not in the ID. + result.check_id = finding.title; + } result.timestamp = std::chrono::system_clock::now(); // remediation/reference are first-class ScanResult fields (rendered @@ -520,6 +528,7 @@ bool PrivEscAnalyzer::CheckUacPosture() { PrivEscFinding f; f.category = L"Registry"; f.title = L"UAC disabled (EnableLUA=0)"; + f.fact_key = L"uac-disabled"; f.risk_level = SecurityLevel::High; f.description = L"User Account Control is turned off. Administrative tokens " L"are not split, so any code run by an admin user executes fully elevated " @@ -830,23 +839,28 @@ bool PrivEscAnalyzer::CheckExtendedAutoruns() { bool PrivEscAnalyzer::CheckMissingPatches() { std::set kbs = Core::Utils::GetInstalledHotfixes(); - uint32_t build = Core::Utils::GetOsBuildNumber(); - StringVector hints = Core::Utils::MissingLpePatchHints(build, kbs); + const uint32_t build = Core::Utils::GetOsBuildNumber(); + const uint32_t ubr = Core::Utils::GetOsUbr(); + const bool is_dc = Core::Utils::GetOsClassification().family == Core::Utils::OsFamily::DomainController; + const auto hints = Core::Utils::MissingLpePatchHints(build, kbs, is_dc); if (hints.empty()) return true; for (const auto& hint : hints) { PrivEscFinding f; f.category = L"Patch"; f.title = L"Potential missing LPE patch"; - f.risk_level = SecurityLevel::Medium; // candidate; cumulative updates may supersede + f.instance_key = hint.cve; // One finding per CVE, each with its own ID. + f.confidence = Confidence::Low; // A KB-list heuristic, not proof. + f.risk_level = SecurityLevel::Medium; // Candidate; cumulative updates may supersede. f.description = L"A well-known local-privilege-escalation issue has no fixing hotfix among " L"the installed KBs. Cumulative updates can supersede the listed KBs, so confirm the " L"effective patch level before relying on this."; - f.evidence = hint + L" | OSBuild=" + std::to_wstring(build) + + f.evidence = hint.message + L" | OSBuild=" + std::to_wstring(build) + + (ubr ? L"." + std::to_wstring(ubr) : String()) + L" | InstalledHotfixes=" + std::to_wstring(kbs.size()); f.remediation = L"Verify the host's patch level (installed cumulative update) and apply " L"the latest security updates if the issue is genuinely unpatched."; - f.reference = L"MITRE ATT&CK T1068 (Exploitation for Privilege Escalation)"; + f.reference = L"MITRE ATT&CK T1068 (Exploitation for Privilege Escalation) / " + hint.cve; Record(f); } return true; @@ -909,20 +923,21 @@ bool PrivEscAnalyzer::CheckCredentialExposure() { if (n) { std::vector b(n); if (GetEnvironmentVariableW(L"USERPROFILE", b.data(), n)) userprofile.assign(b.data()); } } if (!userprofile.empty()) { - struct Art { String path; const wchar_t* what; const wchar_t* ref; }; + struct Art { String path; const wchar_t* what; const wchar_t* ref; const wchar_t* key; }; const Art arts[] = { { userprofile + L"\\AppData\\Roaming\\Microsoft\\Windows\\PowerShell\\PSReadline\\ConsoleHost_history.txt", - L"PowerShell console history (often contains passwords in command lines)", L"MITRE ATT&CK T1552.003" }, - { userprofile + L"\\.aws\\credentials", L"AWS credentials file", L"MITRE ATT&CK T1552.001" }, - { userprofile + L"\\.azure\\accessTokens.json", L"Azure CLI token cache", L"MITRE ATT&CK T1552.001" }, - { userprofile + L"\\.kube\\config", L"Kubernetes kubeconfig (cluster credentials)", L"MITRE ATT&CK T1552.001" }, - { userprofile + L"\\AppData\\Roaming\\Microsoft\\Credentials", L"DPAPI-protected Credential Manager store", L"MITRE ATT&CK T1555.004" }, + L"PowerShell console history (often contains passwords in command lines)", L"MITRE ATT&CK T1552.003", L"powershell-history" }, + { userprofile + L"\\.aws\\credentials", L"AWS credentials file", L"MITRE ATT&CK T1552.001", L"aws-credentials" }, + { userprofile + L"\\.azure\\accessTokens.json", L"Azure CLI token cache", L"MITRE ATT&CK T1552.001", L"azure-token-cache" }, + { userprofile + L"\\.kube\\config", L"Kubernetes kubeconfig (cluster credentials)", L"MITRE ATT&CK T1552.001", L"kubeconfig" }, + { userprofile + L"\\AppData\\Roaming\\Microsoft\\Credentials", L"DPAPI-protected Credential Manager store", L"MITRE ATT&CK T1555.004", L"dpapi-credential-store" }, }; for (const auto& a : arts) { bool exists = Core::Utils::FileExists(a.path) || Core::Utils::DirectoryExists(a.path); if (!exists) continue; PrivEscFinding f; f.category = L"Credentials"; f.title = L"Credential artefact present"; + f.instance_key = a.key; // Several artefacts, one rule: each gets its own ID. f.risk_level = SecurityLevel::Low; f.description = String(L"A file/location that commonly stores recoverable secrets is present: ") + a.what + L". SysRecon reports its presence only and does not read the contents."; diff --git a/modules/registry/registry_analyzer.cpp b/modules/registry/registry_analyzer.cpp index aaf6025..770c363 100644 --- a/modules/registry/registry_analyzer.cpp +++ b/modules/registry/registry_analyzer.cpp @@ -271,6 +271,9 @@ bool RegistryAnalyzer::AnalyzePolicies() { result.details[L"key"] = subkey; result.details[L"value"] = value_name; result.details[L"data"] = value_str; + // The Privilege Escalation module reports the same value (with + // remediation); the engine keeps one of the two. + result.fact_key = L"uac-disabled"; scan_results_.push_back(result); } diff --git a/modules/reporting/report_generator.cpp b/modules/reporting/report_generator.cpp index 9242dba..05c8c04 100644 --- a/modules/reporting/report_generator.cpp +++ b/modules/reporting/report_generator.cpp @@ -64,7 +64,19 @@ bool ReportGenerator::GenerateJSON(const String& filename) { // Scan information j["scan_info"]["version"] = Core::Utils::WideToUtf8(SYSRECON_VERSION); + // "timestamp" is local time without a zone (kept: it is the report file + // name stamp and older tooling reads it). "generated_utc" and + // "utc_offset_minutes" are the unambiguous form. j["scan_info"]["timestamp"] = Core::Utils::WideToUtf8(Core::Utils::GetCurrentTimestamp()); + { + const auto now = std::chrono::system_clock::now(); + j["scan_info"]["generated_utc"] = Core::Utils::WideToUtf8(Core::Utils::FormatIso8601Utc(now)); + j["scan_info"]["utc_offset_minutes"] = Core::Utils::LocalUtcOffsetMinutes(now); + } + // First versioned report format. Additive since 1.8.0: scoring model v2, + // finding IDs with an instance part, ISO-8601 times. Finding schema v2 + // (typed evidence, mandatory remediation) will bump this to 2. + j["scan_info"]["schema_version"] = 1; j["scan_info"]["hostname"] = Core::Utils::WideToUtf8(Core::Utils::GetComputerName()); j["scan_info"]["os_version"] = Core::Utils::WideToUtf8(Core::Utils::GetWindowsVersion()); j["scan_info"]["current_user"] = Core::Utils::WideToUtf8(Core::Utils::GetCurrentUserName()); @@ -73,6 +85,12 @@ bool ReportGenerator::GenerateJSON(const String& filename) { // System information j["system_info"]["computer_name"] = Core::Utils::WideToUtf8(system_info_.computer_name); j["system_info"]["os_version"] = Core::Utils::WideToUtf8(system_info_.os_version); + // Host role: build numbers alone cannot tell Windows 11 from Windows + // Server 2025, and several checks depend on the difference. + j["system_info"]["os_name"] = Core::Utils::WideToUtf8(system_info_.os_name); + j["system_info"]["os_family"] = Core::Utils::WideToUtf8(system_info_.os_family); + j["system_info"]["os_build"] = Core::Utils::WideToUtf8(system_info_.os_build); + j["system_info"]["os_ubr"] = system_info_.os_ubr; j["system_info"]["architecture"] = Core::Utils::WideToUtf8(system_info_.architecture); j["scan_info"]["elevated"] = system_info_.elevated; @@ -148,9 +166,18 @@ bool ReportGenerator::GenerateJSON(const String& filename) { j["summary"]["medium"] = medium_count; j["summary"]["low"] = low_count; j["summary"]["total"] = scan_results_.size(); - const int score = CalculateRiskScore(); - j["summary"]["risk_score"] = score; - j["summary"]["rating"] = Core::Utils::WideToUtf8(RiskRating(score)); + const Core::Scoring::Result risk = RiskAssessment(); + j["summary"]["risk_score"] = risk.score; + j["summary"]["rating"] = Core::Utils::WideToUtf8(risk.rating); + // Which model produced the score, so a consumer can tell 1.8 numbers + // (a linear sum that saturated at 100) from these (tier bands). + j["summary"]["scoring"] = { + {"model_version", Core::Scoring::kModelVersion}, + {"rule", "rating is the worst effective severity tier present; the score stays inside that tier's band"}, + {"tier_bands", {{"critical", {85, 100}}, {"high", {60, 84}}, {"medium", {30, 59}}, {"low", {5, 29}}}}, + {"low_confidence_demotes_one_tier", true}, + {"repeat_weight", Core::Scoring::kRepeatWeight} + }; // Wide path: narrowing to the ANSI codepage made any non-ASCII output // directory fail to open. std::filesystem::path preserves it. @@ -322,11 +349,11 @@ bool ReportGenerator::GenerateHTML(const String& filename) { } // Summary - const int score = CalculateRiskScore(); + const Core::Scoring::Result risk = RiskAssessment(); html << L"

📊 Summary

\n"; html << L"
\n"; - html << L"

Overall risk rating: " << H(RiskRating(score)) - << L" (score " << score << L"/100)

\n"; + html << L"

Overall risk rating: " << H(risk.rating) + << L" (score " << risk.score << L"/100)

\n"; html << L"

Critical: " << critical << L"

\n"; html << L"

High: " << high << L"

\n"; html << L"

Medium: " << medium << L"

\n"; @@ -390,32 +417,8 @@ bool ReportGenerator::GenerateHTML(const String& filename) { return true; } -int ReportGenerator::CalculateRiskScore() const { - int critical = 0, high = 0, medium = 0, low = 0; - for (const auto& r : scan_results_) { - switch (r.risk_level) { - case SecurityLevel::Critical: ++critical; break; - case SecurityLevel::High: ++high; break; - case SecurityLevel::Medium: ++medium; break; - case SecurityLevel::Low: ++low; break; - } - } - int score = critical * 25 + high * 10 + medium * 4 + low; - return score > 100 ? 100 : score; -} - -String ReportGenerator::RiskRating(int score) const { - int critical = 0, high = 0, medium = 0; - for (const auto& r : scan_results_) { - if (r.risk_level == SecurityLevel::Critical) ++critical; - else if (r.risk_level == SecurityLevel::High) ++high; - else if (r.risk_level == SecurityLevel::Medium) ++medium; - } - if (critical > 0 || score >= 60) return L"Critical"; - if (high > 0 || score >= 30) return L"High"; - if (medium > 0 || score >= 10) return L"Moderate"; - if (score > 0) return L"Low"; - return L"Minimal"; +Core::Scoring::Result ReportGenerator::RiskAssessment() const { + return Core::Scoring::Compute(scan_results_); } namespace { diff --git a/src/core/engine.cpp b/src/core/engine.cpp index 0f79380..f280303 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -491,6 +491,27 @@ void ScanEngine::CollectResults() { } } + // One fact, one finding: when two modules report the same underlying value + // (UAC disabled is read by both Registry and Privilege Escalation) keep one. + { + const size_t dropped = Findings::DropDuplicateFacts(scan_results_); + if (dropped > 0) { + SYSRECON_LOG_DEBUG(std::to_wstring(dropped) + L" duplicate finding(s) for the same fact were merged"); + } + } + + // Every finding needs a unique ID (baselines, --only/--skip and SARIF rule + // ids key on it). Rules that fire more than once set an instance_key + // themselves; this is the safety net, and a warning says which rule to fix. + { + const size_t renamed = Findings::EnsureUniqueIds(scan_results_); + if (renamed > 0) { + SYSRECON_LOG_WARNING(std::to_wstring(renamed) + + L" finding(s) shared an ID with another finding and were renamed; " + L"the rule that produced them should set an instance_key"); + } + } + // --only / --skip. filtered_count_ = Findings::ApplyFilters(scan_results_, only_patterns_, skip_patterns_); if (filtered_count_ > 0) { @@ -534,6 +555,18 @@ bool ScanEngine::ExportResults(const String& format, const String& filename) { Modules::SystemInfo sys_info; sys_info.computer_name = Core::Utils::GetComputerName(); sys_info.os_version = Core::Utils::GetWindowsVersion(); + { + const auto os = Core::Utils::GetOsClassification(); + switch (os.family) { + case Core::Utils::OsFamily::Client: sys_info.os_family = L"client"; break; + case Core::Utils::OsFamily::Server: sys_info.os_family = L"server"; break; + case Core::Utils::OsFamily::DomainController: sys_info.os_family = L"domain_controller"; break; + default: sys_info.os_family = L"unknown"; break; + } + sys_info.os_name = os.name; + sys_info.os_build = std::to_wstring(Core::Utils::GetOsBuildNumber()); + sys_info.os_ubr = Core::Utils::GetOsUbr(); + } sys_info.architecture = Core::Utils::IsWow64Process() ? L"x86 (WOW64)" : L"x64"; sys_info.scan_time = std::chrono::system_clock::now(); sys_info.sysrecon_version = SYSRECON_VERSION; diff --git a/src/core/findings.cpp b/src/core/findings.cpp index 0591124..622ef1b 100644 --- a/src/core/findings.cpp +++ b/src/core/findings.cpp @@ -18,11 +18,52 @@ wchar_t LowerAscii(wchar_t c) { return (c >= L'A' && c <= L'Z') ? static_cast(c - L'A' + L'a') : c; } +// Reads the next code point from `text` at `i` (advancing i), combining UTF-16 +// surrogate pairs so the result is the same wherever wchar_t is 16 or 32 bits. +// A lone surrogate is returned as is. +unsigned long NextCodePoint(const String& text, size_t& i) { + unsigned long cp = static_cast(text[i]); + ++i; + if (cp >= 0xD800 && cp <= 0xDBFF && i < text.size()) { + const unsigned long lo = static_cast(text[i]); + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + ++i; + } + } + return cp; +} + +// "u" + lower-case hex, at least four digits. +String CodePointToken(unsigned long cp) { + static const wchar_t* digits = L"0123456789abcdef"; + String hex; + do { + hex.insert(hex.begin(), digits[cp & 0xF]); + cp >>= 4; + } while (cp); + while (hex.size() < 4) hex.insert(hex.begin(), L'0'); + return L"u" + hex; +} + +void AppendToken(String& out, bool& pending_dash, const String& token) { + if (!out.empty() && out.back() != L'/' && out.back() != L'*') { + out.push_back(L'-'); + } + out += token; + pending_dash = true; // Whatever follows starts a new token. +} + // Slug that keeps glob wildcards and '/', for patterns. String SlugPattern(const String& pattern) { String out; bool pending_dash = false; - for (wchar_t c : pattern) { + for (size_t i = 0; i < pattern.size();) { + if (static_cast(pattern[i]) > 0x7F) { + AppendToken(out, pending_dash, CodePointToken(NextCodePoint(pattern, i))); + continue; + } + const wchar_t c = pattern[i++]; if (IsAsciiAlnum(c)) { if (pending_dash && !out.empty() && out.back() != L'/' && out.back() != L'*') { out.push_back(L'-'); @@ -49,7 +90,12 @@ String SlugPattern(const String& pattern) { String Slug(const String& text) { String out; bool pending_dash = false; - for (wchar_t c : text) { + for (size_t i = 0; i < text.size();) { + if (static_cast(text[i]) > 0x7F) { + AppendToken(out, pending_dash, CodePointToken(NextCodePoint(text, i))); + continue; + } + const wchar_t c = text[i++]; if (IsAsciiAlnum(c)) { if (pending_dash && !out.empty()) { out.push_back(L'-'); @@ -71,8 +117,67 @@ String FindingId(const String& module_name, const String& item_name) { return module + L"/" + item; } +String CheckId(const ScanResult& result) { + return FindingId(result.module_name, + result.check_id.empty() ? result.item_name : result.check_id); +} + String FindingId(const ScanResult& result) { - return FindingId(result.module_name, result.item_name); + String id = CheckId(result); + const String instance = Slug(result.instance_key); + if (!instance.empty()) { + id += L"#" + instance; + } + return id; +} + +size_t EnsureUniqueIds(ScanResults& results) { + std::unordered_map seen; + size_t renamed = 0; + for (auto& r : results) { + const String id = FindingId(r); + const size_t n = ++seen[id]; + if (n == 1) continue; + // Suffix until free: "dup2" could itself already exist as a real key. + for (size_t k = n;; ++k) { + ScanResult probe = r; + probe.instance_key = (r.instance_key.empty() ? String() : r.instance_key + L"-") + + L"dup" + std::to_wstring(k); + const String candidate = FindingId(probe); + if (seen.find(candidate) == seen.end()) { + r.instance_key = probe.instance_key; + ++seen[candidate]; + ++renamed; + break; + } + } + } + return renamed; +} + +size_t DropDuplicateFacts(ScanResults& results) { + // Index of the survivor of each fact. + std::unordered_map keep; + for (size_t i = 0; i < results.size(); ++i) { + const String& key = results[i].fact_key; + if (key.empty()) continue; + auto it = keep.find(key); + if (it == keep.end()) { + keep.emplace(key, i); + } else if (results[it->second].remediation.empty() && !results[i].remediation.empty()) { + it->second = i; // The richer finding replaces the earlier bare one. + } + } + ScanResults out; + out.reserve(results.size()); + for (size_t i = 0; i < results.size(); ++i) { + const String& key = results[i].fact_key; + if (!key.empty() && keep[key] != i) continue; + out.push_back(std::move(results[i])); + } + const size_t dropped = results.size() - out.size(); + results = std::move(out); + return dropped; } bool GlobMatch(const String& pattern, const String& text) { @@ -186,7 +291,12 @@ BaselineDiff Compare(const ScanResults& current, const Baseline& baseline) { } diff.is_new.reserve(current.size()); for (const auto& r : current) { - auto it = remaining.find(FindingId(r)); + const String id = FindingId(r); + auto it = remaining.find(id); + if ((it == remaining.end() || it->second == 0) && id.find(L'#') != String::npos) { + // Reports before 1.9 knew only the rule, not its instance. + it = remaining.find(CheckId(r)); + } if (it != remaining.end() && it->second > 0) { --it->second; diff.is_new.push_back(false); diff --git a/src/core/scoring.cpp b/src/core/scoring.cpp new file mode 100644 index 0000000..59991f2 --- /dev/null +++ b/src/core/scoring.cpp @@ -0,0 +1,100 @@ +#include "../../include/core/scoring.h" +#include "../../include/core/findings.h" + +#include +#include + +namespace SysRecon { +namespace Core { +namespace Scoring { + +namespace { + +struct Band { double floor; double cap; double k; }; + +Band BandFor(SecurityLevel tier) { + switch (tier) { + case SecurityLevel::Critical: return {85, 100, 0.500}; + case SecurityLevel::High: return {60, 84, 0.200}; + case SecurityLevel::Medium: return {30, 59, 0.060}; + case SecurityLevel::Low: return { 5, 29, 0.015}; + } + return {5, 29, 0.015}; +} + +double ConfidenceFactor(Confidence c) { + switch (c) { + case Confidence::High: return 1.0; + case Confidence::Medium: return 0.75; + case Confidence::Low: return 0.5; + } + return 1.0; +} + +} // namespace + +SecurityLevel EffectiveTier(SecurityLevel severity, Confidence confidence) { + if (confidence != Confidence::Low) return severity; + switch (severity) { + case SecurityLevel::Critical: return SecurityLevel::High; + case SecurityLevel::High: return SecurityLevel::Medium; + case SecurityLevel::Medium: return SecurityLevel::Low; + case SecurityLevel::Low: return SecurityLevel::Low; + } + return severity; +} + +String RatingForTier(SecurityLevel tier) { + switch (tier) { + case SecurityLevel::Critical: return L"Critical"; + case SecurityLevel::High: return L"High"; + case SecurityLevel::Medium: return L"Moderate"; + case SecurityLevel::Low: return L"Low"; + } + return L"Low"; +} + +Result Compute(const std::vector& entries) { + Result result; + if (entries.empty()) { + result.rating = L"Minimal"; + return result; + } + + int worst = -1; // Highest SecurityLevel value present. + double survive = 1.0; // prod(1 - q_i) + std::unordered_map seen; + for (const auto& e : entries) { + const SecurityLevel tier = EffectiveTier(e.severity, e.confidence); + worst = std::max(worst, static_cast(tier)); + const bool repeat = seen[e.check_id]++ > 0; + const double q = BandFor(tier).k * ConfidenceFactor(e.confidence) * + (repeat ? kRepeatWeight : 1.0); + survive *= (1.0 - q); + ++result.counted; + } + + const SecurityLevel top = static_cast(worst); + const Band band = BandFor(top); + const double sat = 1.0 - survive; + double score = band.floor + (band.cap - band.floor) * sat; + // Round half up, then clamp into the band (guards float noise at the cap). + int rounded = static_cast(std::floor(score + 0.5)); + rounded = std::max(static_cast(band.floor), std::min(static_cast(band.cap), rounded)); + result.score = rounded; + result.rating = RatingForTier(top); + return result; +} + +Result Compute(const ScanResults& results) { + std::vector entries; + entries.reserve(results.size()); + for (const auto& r : results) { + entries.push_back({r.risk_level, r.confidence, Findings::CheckId(r)}); + } + return Compute(entries); +} + +} +} +} diff --git a/src/core/utils.cpp b/src/core/utils.cpp index 0280468..3c35a93 100644 --- a/src/core/utils.cpp +++ b/src/core/utils.cpp @@ -133,9 +133,15 @@ String GetCurrentUserName() { return L"Unknown"; } -String GetWindowsVersion() { +namespace { + +struct RawOsVersion { DWORD major = 0, minor = 0, build = 0; + DWORD product_type = 0; // OSVERSIONINFOEX::wProductType +}; +RawOsVersion QueryOsVersion() { + RawOsVersion v; HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); if (ntdll) { typedef LONG (WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW); @@ -146,30 +152,37 @@ String GetWindowsVersion() { reinterpret_cast(GetProcAddress(ntdll, "RtlGetVersion"))); if (RtlGetVersion) { - RTL_OSVERSIONINFOW rovi = {}; + // The EX structure carries wProductType (workstation / domain + // controller / server); RtlGetVersion fills it when the size says so. + RTL_OSVERSIONINFOEXW rovi = {}; rovi.dwOSVersionInfoSize = sizeof(rovi); - if (RtlGetVersion(&rovi) == 0) { - major = rovi.dwMajorVersion; - minor = rovi.dwMinorVersion; - build = rovi.dwBuildNumber; + if (RtlGetVersion(reinterpret_cast(&rovi)) == 0) { + v.major = rovi.dwMajorVersion; + v.minor = rovi.dwMinorVersion; + v.build = rovi.dwBuildNumber; + v.product_type = rovi.wProductType; } } } + return v; +} - std::wstringstream ss; - ss << L"Windows "; - if (major == 10) { - ss << (build >= 22000 ? L"11" : L"10"); - } else if (major == 6) { - if (minor == 3) ss << L"8.1"; - else if (minor == 2) ss << L"8"; - else if (minor == 1) ss << L"7"; - else ss << L"Vista"; - } else { - ss << major << L"." << minor; - } - ss << L" (Build " << build << L")"; - return ss.str(); +} // namespace + +OsClassification GetOsClassification() { + const RawOsVersion v = QueryOsVersion(); + return ClassifyWindows(v.major, v.minor, v.build, v.product_type); +} + +String GetWindowsVersion() { + const RawOsVersion v = QueryOsVersion(); + const OsClassification c = ClassifyWindows(v.major, v.minor, v.build, v.product_type); + return c.name + L" (Build " + std::to_wstring(v.build) + L")"; +} + +uint32_t GetOsUbr() { + return static_cast(ReadRegistryDword( + HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", L"UBR")); } String GetSystemDirectory() { @@ -778,19 +791,7 @@ std::set GetInstalledHotfixes() { } uint32_t GetOsBuildNumber() { - uint32_t build = 0; - HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); - if (ntdll) { - typedef LONG (WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW); - auto rtl = reinterpret_cast( - reinterpret_cast(GetProcAddress(ntdll, "RtlGetVersion"))); - if (rtl) { - RTL_OSVERSIONINFOW vi{}; - vi.dwOSVersionInfoSize = sizeof(vi); - if (rtl(&vi) == 0) build = vi.dwBuildNumber; - } - } - return build; + return QueryOsVersion().build; } } diff --git a/src/core/utils_common.cpp b/src/core/utils_common.cpp index 39af955..79039f4 100644 --- a/src/core/utils_common.cpp +++ b/src/core/utils_common.cpp @@ -473,6 +473,36 @@ String GetCurrentTimestamp() { return FormatTimestamp(std::chrono::system_clock::now(), L"%Y%m%d_%H%M%S"); } +namespace { +bool ToUtcTime(std::time_t t, std::tm& out) { +#ifdef _WIN32 + return gmtime_s(&out, &t) == 0; +#else + return gmtime_r(&t, &out) != nullptr; +#endif +} +} // namespace + +String FormatIso8601Utc(const std::chrono::system_clock::time_point& time) { + const std::time_t t = std::chrono::system_clock::to_time_t(time); + std::tm tm_buf{}; + if (!ToUtcTime(t, tm_buf)) return L""; + std::wstringstream ss; + ss << std::put_time(&tm_buf, L"%Y-%m-%dT%H:%M:%SZ"); + return ss.str(); +} + +int LocalUtcOffsetMinutes(const std::chrono::system_clock::time_point& time) { + const std::time_t t = std::chrono::system_clock::to_time_t(time); + std::tm local{}, utc{}; + if (!ToLocalTime(t, local) || !ToUtcTime(t, utc)) return 0; + int minutes = (local.tm_hour * 60 + local.tm_min) - (utc.tm_hour * 60 + utc.tm_min); + // The two broken-down times can straddle midnight: fold into (-12h, +14h]. + if (minutes > 14 * 60) minutes -= 24 * 60; + if (minutes <= -12 * 60) minutes += 24 * 60; + return minutes; +} + Timer::Timer() : is_running_(false) { Reset(); } @@ -578,38 +608,98 @@ String CalculateSHA256(const String& file_path) { return out; } -StringVector MissingLpePatchHints(uint32_t os_build, const std::set& installed_kbs) { +OsClassification ClassifyWindows(uint32_t major, uint32_t minor, uint32_t build, uint32_t product_type) { + OsClassification c; + const bool is_dc = product_type == 2; + const bool is_server = product_type == 3 || is_dc; + c.family = is_dc ? OsFamily::DomainController : (is_server ? OsFamily::Server : OsFamily::Client); + + if (major == 10) { + if (is_server) { + if (build >= 26100) c.name = L"Windows Server 2025"; + else if (build >= 25398) c.name = L"Windows Server, version 23H2"; + else if (build >= 20348) c.name = L"Windows Server 2022"; + else if (build >= 17763) c.name = L"Windows Server 2019"; + else if (build >= 14393) c.name = L"Windows Server 2016"; + else c.name = L"Windows Server (build " + std::to_wstring(build) + L")"; + } else { + c.name = build >= 22000 ? L"Windows 11" : L"Windows 10"; + } + } else if (major == 6) { + if (is_server) { + if (minor == 3) c.name = L"Windows Server 2012 R2"; + else if (minor == 2) c.name = L"Windows Server 2012"; + else if (minor == 1) c.name = L"Windows Server 2008 R2"; + else c.name = L"Windows Server 2008"; + } else { + if (minor == 3) c.name = L"Windows 8.1"; + else if (minor == 2) c.name = L"Windows 8"; + else if (minor == 1) c.name = L"Windows 7"; + else c.name = L"Windows Vista"; + } + } else if (major == 0) { + c.family = OsFamily::Unknown; + c.name = L"Windows"; + } else { + c.name = L"Windows " + std::to_wstring(major) + L"." + std::to_wstring(minor); + } + return c; +} + +std::vector MissingLpePatchHints(uint32_t os_build, const std::set& installed_kbs, + bool is_domain_controller) { // Curated, deliberately small table of high-signal local-privilege-escalation - // issues. Each entry's `fix_kbs` lists KBs that (among others) addressed it; - // if the host's build is in range and none of those KBs are installed, the - // CVE is reported as a candidate. This is a hint, not proof: cumulative - // updates supersede these KBs, so findings are marked "verify". + // issues. Each entry lists the builds that existed *before* the fix + // (affected) and KBs that (among others) addressed it. A CVE is a candidate + // only on an affected build, only for the right role, and only when none of + // the fix KBs is installed. This is a hint, not proof: cumulative updates + // supersede these KBs, so findings are marked low confidence ("verify"). + struct Range { uint32_t from, to; }; struct Entry { const wchar_t* cve; const wchar_t* name; - uint32_t min_build; // inclusive; 0 = any - uint32_t max_build; // inclusive; 0 = any + std::vector affected; // inclusive build ranges; empty = any build + bool dc_only; // domain controllers only std::vector fix_kbs; }; + // Build numbers: 7601 Win7/2008R2, 9600 8.1/2012R2, 14393 Server 2016, + // 17134 Win10 1803, 17763 Win10 1809/Server 2019, 19041-19043 Win10 + // 2004..21H1, 19044 21H2, 19045 22H2, 20348 Server 2022, 22000 Win11 21H2, + // 22621+ Win11 22H2 and later. Windows 11 and Windows 10 21H2+ were released + // after the July 2021 fixes; Windows 10 22H2 and Windows 11 22H2+ after the + // January 2022 fix. static const std::vector table = { - { L"CVE-2021-34527", L"PrintNightmare (Print Spooler RCE/LPE)", 0, 0, + // July 2021 fixes. Builds up to Windows 10 21H1 (19043) pre-date them. + { L"CVE-2021-34527", L"PrintNightmare (Print Spooler RCE/LPE)", {{0, 19043}}, false, { L"KB5004945", L"KB5004946", L"KB5004947", L"KB5004948", L"KB5004950" } }, - { L"CVE-2021-1675", L"Print Spooler LPE", 0, 0, + { L"CVE-2021-1675", L"Print Spooler LPE", {{0, 19043}}, false, { L"KB5003635", L"KB5004945" } }, - { L"CVE-2021-36934", L"HiveNightmare / SeriousSAM (SAM hive readable)", 0, 0, + // HiveNightmare: Windows 10 1809 through 21H1 (and Server 2019). + { L"CVE-2021-36934", L"HiveNightmare / SeriousSAM (SAM hive readable)", {{17763, 19043}}, false, { L"KB5004605", L"KB5004237" } }, - { L"CVE-2021-1732", L"Win32k window-object LPE", 0, 0, + // February 2021 fix: Windows 10 1803 through 20H2 (19042). + { L"CVE-2021-1732", L"Win32k window-object LPE", {{17134, 19042}}, false, { L"KB4601319", L"KB4601315" } }, - { L"CVE-2022-21882", L"Win32k LPE (CVE-2021-1732 bypass)", 0, 0, + // January 2022 fix: Windows 10 1809-21H2, Server 2019/2022, original Windows 11. + { L"CVE-2022-21882", L"Win32k LPE (CVE-2021-1732 bypass)", + {{17763, 19044}, {20348, 20348}, {22000, 22000}}, false, { L"KB5009543", L"KB5009566" } }, - { L"CVE-2020-1472", L"Zerologon (Netlogon)", 0, 0, + // Zerologon is a Netlogon flaw on domain controllers (Server 2019 and + // older); it is not a local privilege escalation on workstations. + { L"CVE-2020-1472", L"Zerologon (Netlogon, domain controllers)", {{0, 17763}}, true, { L"KB4565349", L"KB4571756", L"KB4571694" } }, }; - StringVector hints; + std::vector hints; for (const auto& e : table) { - if (e.min_build && os_build < e.min_build) continue; - if (e.max_build && os_build > e.max_build) continue; + if (e.dc_only && !is_domain_controller) continue; + if (!e.affected.empty()) { + bool in_range = false; + for (const auto& r : e.affected) { + if (os_build >= r.from && os_build <= r.to) { in_range = true; break; } + } + if (!in_range) continue; + } bool patched = false; for (const auto& kb : e.fix_kbs) { if (installed_kbs.count(kb)) { patched = true; break; } @@ -620,7 +710,12 @@ StringVector MissingLpePatchHints(uint32_t os_build, const std::set& ins if (i) fixes += L", "; fixes += e.fix_kbs[i]; } - hints.push_back(String(e.cve) + L" (" + e.name + L") - no fixing hotfix found (" + fixes + L")"); + LpePatchHint h; + h.cve = e.cve; + h.name = e.name; + h.fix_kbs = fixes; + h.message = String(e.cve) + L" (" + e.name + L") - no fixing hotfix found (" + fixes + L")"; + hints.push_back(std::move(h)); } return hints; } diff --git a/tests/test_findings.cpp b/tests/test_findings.cpp index b6f6a0a..9519d1b 100644 --- a/tests/test_findings.cpp +++ b/tests/test_findings.cpp @@ -1,5 +1,7 @@ #include +#include + #include "core/findings.h" using namespace SysRecon; @@ -24,8 +26,8 @@ TEST(Findings, SlugCollapsesSeparatorsAndLowercases) { EXPECT_EQ(Slug(L" Writable directory on PATH: C:\\Tools\\ "), L"writable-directory-on-path-c-tools"); EXPECT_EQ(Slug(L"TCP 445 (System)"), L"tcp-445-system"); EXPECT_EQ(Slug(L"---"), L""); - // Non-ASCII letters are separators: IDs stay ASCII. - EXPECT_EQ(Slug(L"Benutzer \u00fcber"), L"benutzer-ber"); + // Non-ASCII is encoded, never dropped (BUG-003). + EXPECT_EQ(Slug(L"Benutzer \u00fcber"), L"benutzer-u00fc-ber"); } TEST(Findings, FindingIdDependsOnlyOnModuleAndItem) { @@ -158,3 +160,125 @@ TEST(Findings, CompareAgainstEmptyBaselineMarksEverythingNew) { EXPECT_EQ(d.unchanged_count, 0u); EXPECT_TRUE(d.resolved.empty()); } + +// ---- BUG-003: identity --------------------------------------------------- + +namespace { +ScanResult Instance(const String& module, const String& check, const String& key, + const String& item = L"") { + ScanResult r = Make(module, item.empty() ? check : item); + r.check_id = check; + r.instance_key = key; + return r; +} +} // namespace + +TEST(Findings, InstanceKeyDistinguishesFindingsOfOneRule) { + const ScanResult a = Instance(L"Privilege Escalation", L"Potential missing LPE patch", L"CVE-2021-34527"); + const ScanResult b = Instance(L"Privilege Escalation", L"Potential missing LPE patch", L"CVE-2020-1472"); + EXPECT_EQ(FindingId(a), L"privilege-escalation/potential-missing-lpe-patch#cve-2021-34527"); + EXPECT_NE(FindingId(a), FindingId(b)); + EXPECT_EQ(CheckId(a), CheckId(b)); + EXPECT_EQ(CheckId(a), L"privilege-escalation/potential-missing-lpe-patch"); +} + +TEST(Findings, IdIgnoresTheDisplayTitleWhenCheckIdIsSet) { + // The title can carry a hostname; the ID must not. + const ScanResult a = Instance(L"Event Logs", L"Account created", L"vagrant", L"Account created: WIN-AAAA\\vagrant"); + const ScanResult b = Instance(L"Event Logs", L"Account created", L"vagrant", L"Account created: DESKTOP-BBBB\\vagrant"); + EXPECT_EQ(FindingId(a), FindingId(b)); + EXPECT_EQ(FindingId(a), L"event-logs/account-created#vagrant"); +} + +TEST(Findings, LegacyIdsAreUnchangedWithoutCheckIdOrInstance) { + EXPECT_EQ(FindingId(Make(L"Network", L"TCP 445 (System)")), L"network/tcp-445-system"); +} + +TEST(Findings, NonAsciiNamesAreEncodedNotCollapsed) { + EXPECT_EQ(Slug(L"用户"), L"u7528-u6237"); + EXPECT_NE(Slug(L"用户"), Slug(L"éé")); + EXPECT_NE(FindingId(L"User Accounts", L"用户"), FindingId(L"User Accounts", L"使用")); + EXPECT_EQ(FindingId(L"User Accounts", L"用户"), L"user-accounts/u7528-u6237"); + // Astral code point (UTF-16 surrogate pair on Windows, one wchar_t elsewhere) gives the same token. + String astral; + if (sizeof(wchar_t) == 2) { astral.push_back(0xD83D); astral.push_back(0xDE00); } + else astral.push_back(static_cast(0x1F600)); + EXPECT_EQ(Slug(astral), L"u1f600"); +} + +TEST(Findings, EnsureUniqueIdsRenamesOnlyTheRepeats) { + ScanResults r = {Make(L"A", L"same"), Make(L"A", L"same"), Make(L"A", L"other"), Make(L"A", L"same")}; + EXPECT_EQ(EnsureUniqueIds(r), 2u); + std::set ids; + for (const auto& x : r) ids.insert(FindingId(x)); + EXPECT_EQ(ids.size(), r.size()); + EXPECT_EQ(FindingId(r[0]), L"a/same"); // First occurrence keeps its ID. + EXPECT_EQ(FindingId(r[2]), L"a/other"); + // Deterministic: running again changes nothing. + EXPECT_EQ(EnsureUniqueIds(r), 0u); +} + +TEST(Findings, EnsureUniqueIdsKeepsInstanceKeysThatAreAlreadyUnique) { + ScanResults r = {Instance(L"P", L"Patch", L"CVE-1"), Instance(L"P", L"Patch", L"CVE-2")}; + EXPECT_EQ(EnsureUniqueIds(r), 0u); +} + +TEST(Findings, CompareMatchesA1x8BaselineThatKnewOnlyTheRule) { + // 1.8.0 reported six patch findings under one ID. + Baseline b; + for (int i = 0; i < 6; ++i) { + b.entries.push_back({L"privilege-escalation/potential-missing-lpe-patch", L"Privilege Escalation", + L"Potential missing LPE patch", L"Medium"}); + } + const ScanResults current = { + Instance(L"Privilege Escalation", L"Potential missing LPE patch", L"CVE-2021-34527"), + Instance(L"Privilege Escalation", L"Potential missing LPE patch", L"CVE-2020-1472"), + }; + const BaselineDiff d = Compare(current, b); + EXPECT_EQ(d.new_count, 0u); + EXPECT_EQ(d.unchanged_count, 2u); + EXPECT_EQ(d.resolved.size(), 4u); // The other four are no longer reported. +} + +TEST(Findings, CompareDistinguishesInstancesOnceTheBaselineKnowsThem) { + Baseline b; + b.entries.push_back({L"privilege-escalation/potential-missing-lpe-patch#cve-1", L"Privilege Escalation", + L"Potential missing LPE patch", L"Medium"}); + const ScanResults current = { + Instance(L"Privilege Escalation", L"Potential missing LPE patch", L"CVE-2"), + }; + const BaselineDiff d = Compare(current, b); + EXPECT_EQ(d.new_count, 1u); + EXPECT_EQ(d.resolved.size(), 1u); +} + +// ---- BUG-006: one fact, one finding --------------------------------------- + +TEST(Findings, DropDuplicateFactsKeepsTheFindingWithRemediation) { + ScanResult registry = Make(L"Registry Policy", L"UAC Disabled"); + registry.fact_key = L"uac-disabled"; + ScanResult privesc = Make(L"Privilege Escalation", L"UAC disabled (EnableLUA=0)"); + privesc.fact_key = L"uac-disabled"; + privesc.remediation = L"Re-enable UAC"; + ScanResults r = {Make(L"A", L"x"), registry, privesc, Make(L"B", L"y")}; + EXPECT_EQ(DropDuplicateFacts(r), 1u); + ASSERT_EQ(r.size(), 3u); + EXPECT_EQ(r[0].item_name, L"x"); + EXPECT_EQ(r[1].item_name, L"UAC disabled (EnableLUA=0)"); // the richer one, in the earlier slot + EXPECT_EQ(r[2].item_name, L"y"); +} + +TEST(Findings, DropDuplicateFactsKeepsTheFirstWhenBothAreEqual) { + ScanResult a = Make(L"M1", L"a"); a.fact_key = L"f"; + ScanResult b = Make(L"M2", L"b"); b.fact_key = L"f"; + ScanResults r = {a, b}; + EXPECT_EQ(DropDuplicateFacts(r), 1u); + ASSERT_EQ(r.size(), 1u); + EXPECT_EQ(r[0].item_name, L"a"); +} + +TEST(Findings, DropDuplicateFactsNeverMergesFindingsWithoutAFactKey) { + ScanResults r = {Make(L"A", L"same"), Make(L"A", L"same")}; + EXPECT_EQ(DropDuplicateFacts(r), 0u); + EXPECT_EQ(r.size(), 2u); +} diff --git a/tests/test_scoring.cpp b/tests/test_scoring.cpp new file mode 100644 index 0000000..b89c60f --- /dev/null +++ b/tests/test_scoring.cpp @@ -0,0 +1,124 @@ +#include +#include + +#include "core/scoring.h" + +using namespace SysRecon; +using namespace SysRecon::Core::Scoring; + +namespace { + +Entry E(SecurityLevel s, Confidence c = Confidence::High, const String& check = L"") { + static int counter = 0; + // Distinct check by default, so the repeat down-weighting only applies when asked for. + return {s, c, check.empty() ? L"check-" + std::to_wstring(counter++) : check}; +} + +std::vector Many(SecurityLevel s, int n) { + std::vector v; + for (int i = 0; i < n; ++i) v.push_back(E(s)); + return v; +} + +} // namespace + +TEST(Scoring, NothingIsMinimal) { + const Result r = Compute(std::vector{}); + EXPECT_EQ(r.score, 0); + EXPECT_EQ(r.rating, L"Minimal"); +} + +TEST(Scoring, ReferenceValuesFromTheDesign) { + EXPECT_EQ(Compute({E(SecurityLevel::High)}).score, 65); + EXPECT_EQ(Compute({E(SecurityLevel::Critical)}).score, 93); + EXPECT_EQ(Compute({E(SecurityLevel::Medium)}).score, 32); + EXPECT_EQ(Compute({E(SecurityLevel::Low)}).score, 5); + EXPECT_EQ(Compute({E(SecurityLevel::Critical)}).rating, L"Critical"); + EXPECT_EQ(Compute({E(SecurityLevel::High)}).rating, L"High"); + EXPECT_EQ(Compute({E(SecurityLevel::Medium)}).rating, L"Moderate"); + EXPECT_EQ(Compute({E(SecurityLevel::Low)}).rating, L"Low"); +} + +// BUG-001: a Windows 11 scan with 0 Critical, 7 High, 33 Medium, 31 Low was +// reported as "score 100, Critical". +TEST(Scoring, Bug001_ManyLowerTiersNeverBecomeCritical) { + std::vector v; + for (auto s : {std::make_pair(SecurityLevel::High, 7), std::make_pair(SecurityLevel::Medium, 33), + std::make_pair(SecurityLevel::Low, 31)}) { + auto part = Many(s.first, s.second); + v.insert(v.end(), part.begin(), part.end()); + } + const Result r = Compute(v); + EXPECT_EQ(r.rating, L"High"); + EXPECT_EQ(r.score, 84); + EXPECT_LT(r.score, 100); +} + +TEST(Scoring, ThousandsOfMediumsStayModerate) { + const Result r = Compute(Many(SecurityLevel::Medium, 1000)); + EXPECT_EQ(r.rating, L"Moderate"); + EXPECT_EQ(r.score, 59); +} + +TEST(Scoring, ThousandsOfLowsStayLow) { + const Result r = Compute(Many(SecurityLevel::Low, 5000)); + EXPECT_EQ(r.rating, L"Low"); + EXPECT_LE(r.score, 29); +} + +TEST(Scoring, LowConfidenceDemotesOneTier) { + EXPECT_EQ(EffectiveTier(SecurityLevel::Critical, Confidence::Low), SecurityLevel::High); + EXPECT_EQ(EffectiveTier(SecurityLevel::High, Confidence::Low), SecurityLevel::Medium); + EXPECT_EQ(EffectiveTier(SecurityLevel::Medium, Confidence::Low), SecurityLevel::Low); + EXPECT_EQ(EffectiveTier(SecurityLevel::Low, Confidence::Low), SecurityLevel::Low); + EXPECT_EQ(EffectiveTier(SecurityLevel::High, Confidence::Medium), SecurityLevel::High); + // A heuristic Critical cannot make the headline Critical. + EXPECT_EQ(Compute({E(SecurityLevel::Critical, Confidence::Low)}).rating, L"High"); +} + +TEST(Scoring, RepeatsOfOneRuleCountLess) { + const int six_distinct = Compute(Many(SecurityLevel::Medium, 6)).score; + std::vector same; + for (int i = 0; i < 6; ++i) same.push_back(E(SecurityLevel::Medium, Confidence::High, L"one-rule")); + EXPECT_LT(Compute(same).score, six_distinct); +} + +TEST(Scoring, RatingAndScoreAlwaysAgreeAndAreMonotone) { + std::mt19937 rng(12345); + for (int round = 0; round < 3000; ++round) { + std::vector v; + const int n = rng() % 60; + int previous = 0; + for (int i = 0; i < n; ++i) { + const auto sev = static_cast(rng() % 4); + const auto conf = static_cast(rng() % 3); + v.push_back(E(sev, conf, (rng() % 4 == 0) ? L"shared" : L"")); + const Result r = Compute(v); + + // Never decreases when a finding is added. + ASSERT_GE(r.score, previous); + previous = r.score; + + // The rating is the band of the score. + int lo = 0, hi = 100; + if (r.rating == L"Critical") { lo = 85; hi = 100; } + else if (r.rating == L"High") { lo = 60; hi = 84; } + else if (r.rating == L"Moderate") { lo = 30; hi = 59; } + else if (r.rating == L"Low") { lo = 5; hi = 29; } + ASSERT_GE(r.score, lo); + ASSERT_LE(r.score, hi); + ASSERT_LE(r.score, 100); + } + // No counted Critical (after demotion) means no Critical rating, no + // counted High means no score >= 60. + bool crit = false, high = false; + for (const auto& e : v) { + const auto t = EffectiveTier(e.severity, e.confidence); + crit |= t == SecurityLevel::Critical; + high |= t == SecurityLevel::High; + } + const Result r = Compute(v); + if (!crit) { EXPECT_NE(r.rating, L"Critical"); } + if (!crit && !high) { EXPECT_LE(r.score, 59); } + } +} diff --git a/tests/test_utils_crypto.cpp b/tests/test_utils_crypto.cpp index dcbf199..d7cb332 100644 --- a/tests/test_utils_crypto.cpp +++ b/tests/test_utils_crypto.cpp @@ -59,9 +59,9 @@ TEST(ExtractExecutablePath, Empty) { // --- MissingLpePatchHints --------------------------------------------------- namespace { -bool Mentions(const StringVector& v, const wchar_t* needle) { - for (const auto& s : v) { - if (s.find(needle) != String::npos) return true; +bool Mentions(const std::vector& v, const wchar_t* cve) { + for (const auto& h : v) { + if (h.cve == cve) return true; } return false; } @@ -80,3 +80,99 @@ TEST(MissingLpePatchHints, SuppressedWhenFixingKbPresent) { // An unrelated CVE is still reported. EXPECT_TRUE(Mentions(hints, L"CVE-2021-36934")); } + +TEST(MissingLpePatchHints, HintCarriesTheCveAndAFullMessage) { + const auto hints = Utils::MissingLpePatchHints(19043, {}); + for (const auto& h : hints) { + EXPECT_FALSE(h.cve.empty()); + EXPECT_NE(h.message.find(h.cve), String::npos); + EXPECT_NE(h.message.find(L"KB"), String::npos); + } +} + +// BUG-002: a current Windows 11 (build 26200) was given six findings about +// 2020-2022 vulnerabilities, because the table applied to every build. +TEST(MissingLpePatchHints, Bug002_ModernBuildsGetNoAncientCves) { + const std::set few_hotfixes = {L"KB5000001", L"KB5000002", L"KB5000003", L"KB5000004", L"KB5000005"}; + for (uint32_t build : {26200u, 26100u, 22631u, 22621u, 19045u}) { + EXPECT_TRUE(Utils::MissingLpePatchHints(build, few_hotfixes).empty()) << "build " << build; + EXPECT_TRUE(Utils::MissingLpePatchHints(build, few_hotfixes, true).empty()) << "build " << build; + } +} + +TEST(MissingLpePatchHints, OriginalWindows11StillGetsTheJanuary2022Win32kIssue) { + const auto hints = Utils::MissingLpePatchHints(22000, {}); + EXPECT_TRUE(Mentions(hints, L"CVE-2022-21882")); + EXPECT_FALSE(Mentions(hints, L"CVE-2021-34527")); // Windows 11 shipped after July 2021. +} + +TEST(MissingLpePatchHints, EachCveIsAffectedOnlyWhereItExisted) { + // Windows 10 1803 (17134): Win32k Feb-2021 yes, HiveNightmare (1809+) no. + auto h1803 = Utils::MissingLpePatchHints(17134, {}); + EXPECT_TRUE(Mentions(h1803, L"CVE-2021-1732")); + EXPECT_FALSE(Mentions(h1803, L"CVE-2021-36934")); + // Windows 10 21H1 (19043): HiveNightmare yes, the Feb-2021 Win32k fix already shipped in it. + auto h21h1 = Utils::MissingLpePatchHints(19043, {}); + EXPECT_TRUE(Mentions(h21h1, L"CVE-2021-36934")); + EXPECT_FALSE(Mentions(h21h1, L"CVE-2021-1732")); +} + +// BUG-016: Zerologon is a domain-controller flaw, not a workstation LPE. +TEST(MissingLpePatchHints, Bug016_ZerologonOnlyOnDomainControllers) { + EXPECT_FALSE(Mentions(Utils::MissingLpePatchHints(17763, {}, false), L"CVE-2020-1472")); + EXPECT_TRUE(Mentions(Utils::MissingLpePatchHints(17763, {}, true), L"CVE-2020-1472")); + // A DC on Server 2022 (20348) was never vulnerable. + EXPECT_FALSE(Mentions(Utils::MissingLpePatchHints(20348, {}, true), L"CVE-2020-1472")); +} + +// --- ClassifyWindows (BUG-005) ---------------------------------------------- + +TEST(ClassifyWindows, ServerAndClientShareBuildNumbers) { + // Windows Server 2025 and Windows 11 24H2 are both build 26100. + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 26100, 3).name, L"Windows Server 2025"); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 26100, 3).family, Utils::OsFamily::Server); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 26100, 1).name, L"Windows 11"); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 26100, 1).family, Utils::OsFamily::Client); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 26200, 1).name, L"Windows 11"); +} + +TEST(ClassifyWindows, ServerReleases) { + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 20348, 3).name, L"Windows Server 2022"); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 17763, 3).name, L"Windows Server 2019"); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 14393, 3).name, L"Windows Server 2016"); + EXPECT_EQ(Utils::ClassifyWindows(6, 3, 9600, 3).name, L"Windows Server 2012 R2"); + EXPECT_EQ(Utils::ClassifyWindows(6, 1, 7601, 3).name, L"Windows Server 2008 R2"); +} + +TEST(ClassifyWindows, DomainControllerIsAServerWithItsOwnFamily) { + const auto dc = Utils::ClassifyWindows(10, 0, 20348, 2); + EXPECT_EQ(dc.family, Utils::OsFamily::DomainController); + EXPECT_EQ(dc.name, L"Windows Server 2022"); +} + +TEST(ClassifyWindows, ClientReleases) { + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 19045, 1).name, L"Windows 10"); + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 22000, 1).name, L"Windows 11"); + EXPECT_EQ(Utils::ClassifyWindows(6, 3, 9600, 1).name, L"Windows 8.1"); + EXPECT_EQ(Utils::ClassifyWindows(6, 1, 7601, 1).name, L"Windows 7"); +} + +TEST(ClassifyWindows, UnknownProductTypeIsTreatedAsClientAndNeverCrashes) { + EXPECT_EQ(Utils::ClassifyWindows(10, 0, 19045, 0).family, Utils::OsFamily::Client); + EXPECT_EQ(Utils::ClassifyWindows(0, 0, 0, 0).family, Utils::OsFamily::Unknown); + EXPECT_FALSE(Utils::ClassifyWindows(11, 2, 1, 1).name.empty()); +} + +// --- ISO-8601 timestamps (BUG-015) ------------------------------------------ + +TEST(Timestamp, Iso8601UtcIsUnambiguous) { + EXPECT_EQ(Utils::FormatIso8601Utc(std::chrono::system_clock::from_time_t(0)), L"1970-01-01T00:00:00Z"); + EXPECT_EQ(Utils::FormatIso8601Utc(std::chrono::system_clock::from_time_t(1794156877)), + L"2026-11-08T16:54:37Z"); +} + +TEST(Timestamp, LocalOffsetIsWithinRealWorldRange) { + const int off = Utils::LocalUtcOffsetMinutes(std::chrono::system_clock::now()); + EXPECT_GT(off, -12 * 60); + EXPECT_LE(off, 14 * 60); +} diff --git a/tests/windows/smoke-test.ps1 b/tests/windows/smoke-test.ps1 index b8768bb..c31bb3d 100644 --- a/tests/windows/smoke-test.ps1 +++ b/tests/windows/smoke-test.ps1 @@ -104,6 +104,42 @@ function Assert-ScanComplete($Report, [string]$Name) { Write-Annotation warning "$Name incomplete check: $note" } Assert-True ($Report.summary.total -eq @($Report.results).Count) "$Name summary total matches results" + + # Finding IDs must be unique: baselines, --only/--skip and SARIF rule ids key on them. + $ids = @($Report.results | ForEach-Object { $_.id }) + $dupes = @($ids | Group-Object | Where-Object Count -gt 1 | ForEach-Object Name) + Assert-True ($dupes.Count -eq 0) "$Name finding IDs are unique ($($dupes -join ', '))" + + # The rating must agree with the findings. (1.8.0 printed "Critical, score 100" + # for a host with no Critical finding: thirty Medium findings summed past 100.) + $sum = $Report.summary + if ($sum.critical -eq 0) { + Assert-True ($sum.rating -ne 'Critical') "$Name rating is not Critical without a Critical finding ($($sum.rating), score $($sum.risk_score))" + } + if ($sum.critical -eq 0 -and $sum.high -eq 0) { + Assert-True ($sum.risk_score -le 59) "$Name score stays below the High band without Critical/High findings ($($sum.risk_score))" + } + Assert-True ($Report.summary.scoring.model_version -eq 2) "$Name reports scoring model v2" + + # Unambiguous time, a schema version, and the host role. + Assert-True ($Report.scan_info.generated_utc -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') "$Name has an ISO-8601 UTC time ($($Report.scan_info.generated_utc))" + Assert-True ($Report.scan_info.schema_version -ge 1) "$Name carries a schema_version" + $productType = (Get-CimInstance -ClassName Win32_OperatingSystem).ProductType + $expectedFamily = switch ($productType) { 1 { 'client' } 2 { 'domain_controller' } 3 { 'server' } default { 'unknown' } } + Assert-True ($Report.system_info.os_family -eq $expectedFamily) "$Name detected the host role ($($Report.system_info.os_family); expected $expectedFamily)" + if ($expectedFamily -ne 'client') { + Assert-True ($Report.system_info.os_name -like 'Windows Server*') "$Name does not label a server as a client ($($Report.system_info.os_name))" + } + + # 2020-2022 "missing patch" hints must not appear on a build that shipped after those fixes. + if ([int]$Report.system_info.os_build -ge 22621) { + $patch = @($Report.results | Where-Object { $_.item -eq 'Potential missing LPE patch' }) + Assert-True ($patch.Count -eq 0) "$Name has no stale missing-patch findings on build $($Report.system_info.os_build) ($($patch.Count))" + } + + # UAC disabled is one fact: it must not be reported by two modules. + $uac = @($Report.results | Where-Object { $_.item -like 'UAC disabled*' -or $_.item -eq 'UAC Disabled' }) + Assert-True ($uac.Count -le 1) "$Name reports UAC-disabled once ($($uac.Count))" } # --------------------------------------------------------------------------- From d3a5c63583073cb632debf4203d2069b2ce01b9f Mon Sep 17 00:00:00 2001 From: Xpert <115935628+XpertLambda@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:22:37 +0100 Subject: [PATCH 2/3] Smoke test: read generated_utc as the datetime ConvertFrom-Json produces --- tests/windows/smoke-test.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/windows/smoke-test.ps1 b/tests/windows/smoke-test.ps1 index c31bb3d..4643907 100644 --- a/tests/windows/smoke-test.ps1 +++ b/tests/windows/smoke-test.ps1 @@ -122,7 +122,12 @@ function Assert-ScanComplete($Report, [string]$Name) { Assert-True ($Report.summary.scoring.model_version -eq 2) "$Name reports scoring model v2" # Unambiguous time, a schema version, and the host role. - Assert-True ($Report.scan_info.generated_utc -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') "$Name has an ISO-8601 UTC time ($($Report.scan_info.generated_utc))" + # ConvertFrom-Json turns an ISO-8601 string into a [datetime], so check the parsed + # value: it must be a real time within the last hour (not a zoneless local stamp). + $generated = $Report.scan_info.generated_utc + $generatedOk = ($generated -is [datetime]) -and + ([math]::Abs(((Get-Date).ToUniversalTime() - $generated.ToUniversalTime()).TotalMinutes) -lt 60) + Assert-True $generatedOk "$Name has an ISO-8601 UTC time ($generated)" Assert-True ($Report.scan_info.schema_version -ge 1) "$Name carries a schema_version" $productType = (Get-CimInstance -ClassName Win32_OperatingSystem).ProductType $expectedFamily = switch ($productType) { 1 { 'client' } 2 { 'domain_controller' } 3 { 'server' } default { 'unknown' } } From d37d27a7eaecf6833a8a8072baa825d7ece6dff0 Mon Sep 17 00:00:00 2001 From: Xpert <115935628+XpertLambda@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:22:45 +0100 Subject: [PATCH 3/3] Smoke test: accept generated_utc as text too (Windows PowerShell 5.1) --- tests/windows/smoke-test.ps1 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/windows/smoke-test.ps1 b/tests/windows/smoke-test.ps1 index 4643907..b65c44a 100644 --- a/tests/windows/smoke-test.ps1 +++ b/tests/windows/smoke-test.ps1 @@ -125,8 +125,13 @@ function Assert-ScanComplete($Report, [string]$Name) { # ConvertFrom-Json turns an ISO-8601 string into a [datetime], so check the parsed # value: it must be a real time within the last hour (not a zoneless local stamp). $generated = $Report.scan_info.generated_utc - $generatedOk = ($generated -is [datetime]) -and - ([math]::Abs(((Get-Date).ToUniversalTime() - $generated.ToUniversalTime()).TotalMinutes) -lt 60) + if ($generated -is [string]) { + # Windows PowerShell 5.1 leaves it as text. + $generatedOk = $generated -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$' + } else { + $generatedOk = ($generated -is [datetime]) -and + ([math]::Abs(((Get-Date).ToUniversalTime() - $generated.ToUniversalTime()).TotalMinutes) -lt 60) + } Assert-True $generatedOk "$Name has an ISO-8601 UTC time ($generated)" Assert-True ($Report.scan_info.schema_version -ge 1) "$Name carries a schema_version" $productType = (Get-CimInstance -ClassName Win32_OperatingSystem).ProductType