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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": []
}
```
Expand Down
49 changes: 41 additions & 8 deletions include/core/findings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<module-slug>/<item-slug>", 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:
// "<module-slug>/<check-slug>" one instance per scan
// "<module-slug>/<check-slug>#<instance-slug>" 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
// ("<module-slug>/<item-slug>"), 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: "<module-slug>/<check-slug>" (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);
Expand Down Expand Up @@ -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".
Expand Down
64 changes: 64 additions & 0 deletions include/core/scoring.h
Original file line number Diff line number Diff line change
@@ -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<Entry>& entries);
Result Compute(const ScanResults& results);

// Rating label for a score band (kept for callers that only have the score).
String RatingForTier(SecurityLevel tier);

}
}
}
50 changes: 45 additions & 5 deletions include/core/utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; // "<cve> (<name>) - no fixing hotfix found (<kbs>)"
};

// 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<String>& 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<LpePatchHint> MissingLpePatchHints(uint32_t os_build,
const std::set<String>& installed_kbs,
bool is_domain_controller = false);

bool FileExists(const String& path);
bool DirectoryExists(const String& path);
Expand All @@ -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:
Expand Down Expand Up @@ -171,6 +206,11 @@ StringVector GetTokenGroupNames(); // resolved group account names
// and the OS build number. Used together with MissingLpePatchHints().
std::set<String> 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

}
Expand Down
6 changes: 5 additions & 1 deletion include/modules/eventlogs/event_log_analyzer.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions include/modules/privesc/privesc_analyzer.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions include/modules/reporting/report_generator.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "../../sysrecon.h"
#include "../../core/findings.h"
#include "../../core/scoring.h"

namespace SysRecon {
namespace Modules {
Expand All @@ -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.<ubr>); 0 = unknown.
String architecture;
String processor;
String memory_total;
Expand Down Expand Up @@ -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_;
Expand Down
Loading
Loading