Skip to content

Architecture

Mikhail Deynekin edited this page Oct 21, 2026 · 1 revision

πŸ—οΈ Architecture

How WinSecHunter is built β€” layer model, query pipeline, parser design, catalog schema, type system, and the testing strategy that keeps all of it honest.

← Home


Design principles

  1. Correctness under hostile data. Missing channels, unparseable records, weird IPv6 renderings, 500k-event logs, unreachable hosts β€” every one of these is an expected input, not an exception path.
  2. Pure logic separated from Windows I/O. Parsing, decoding, detection, timeline and rendering are side-effect-free functions of their inputs β†’ the 132-test suite runs on Linux CI without a single event log.
  3. Evidence discipline. UTC everywhere for analysis, dual timestamps on every event, per-host accountability, hash manifests on export.
  4. Pipeline-native. No Write-Host/exit in library code; objects in, objects out; ShouldProcess on everything that mutates.
  5. Boring technology. Dot-sourced module layout, JSON resources, no compiled dependencies, no experimental language features β€” debuggable at 3 a.m. during an incident.

Layer model

flowchart TB
    subgraph PUB["Public API β€” 20 cmdlets (allow-listed in psm1 + psd1)"]
        direction LR
        C1["Collection 🟒<br/>Get-WinSecEventByIp · Trace-WinSecIp<br/>Get-WinSecLogCoverage · Test-WinSecAuditPolicy"]
        C2["Analysis πŸ”΅<br/>Timeline Β· LogonSession Β· BruteForce<br/>Attribution Β· ExpandIpRange"]
        C3["Evidence 🟣<br/>New-WinSecCase<br/>Export-WinSecInvestigation"]
        C4["Utility βšͺ<br/>EventCatalog<br/>Get/Set-Option"]
    end
    subgraph PRIV["Private engine (never exported)"]
        direction LR
        P1["Resource.ps1<br/>catalog load/index<br/>profile→ID resolution"]
        P2["IpUtils.ps1<br/>validate Β· canonicalize<br/>variants Β· regex Β· CIDR"]
        P3["Decode.ps1<br/>NTSTATUS Β· Kerberos<br/>%% Β· logon types Β· WFP"]
        P4["New-WinSecEventQuery<br/>XPath QueryList builder"]
        P5["Invoke-WinSecEventQuery<br/>local βˆ₯ WinRM βˆ₯ CIM βˆ₯ EVTX"]
        P6["ConvertTo-WinSecEvent<br/>record β†’ typed event"]
        P7["ConvertTo-WinSecHtmlReport<br/>inline CSS/SVG renderer"]
        P8["Config.ps1<br/>typed JSON config + cache paths"]
    end
    subgraph TYPES["Types & data"]
        direction LR
        T1["Enums Γ— 8"]
        T2["Classes Γ— 9<br/>WinSecEvent Β· Case Β· Session Β· Finding<br/>Coverage Β· Attribution Β· Bucket Β· AuditGap Β· HostResult"]
        T3["Resources/EventCatalog.json<br/>156 events Β· 14 profiles Β· ATT&CK"]
    end
    PUB --> PRIV --> TYPES
Loading

Loader order (WinSecHunter.psm1): Enums β†’ Classes β†’ Private β†’ Public β€” class definitions reference enum types at compile time, so order is load-bearing. Classes are module-scoped; consumers needing type names use using module WinSecHunter (objects work without it).

The collection pipeline

sequenceDiagram
    participant U as Analyst
    participant G as Get-WinSecEventByIp
    participant S as Scope resolver
    participant Q as Query builder
    participant X as Execution engine
    participant P as Parser
    U->>G: IP(s)/CIDR + window + profile
    G->>G: validate & canonicalize IPs,<br/>compute variants + boundary regex
    G->>S: profile βˆͺ categories βˆͺ ids βˆͺ logs
    S-->>G: {log β†’ event IDs} (catalog-resolved)
    G->>Q: logs, IDs, UTC window, IP variants
    Q-->>G: QueryList XML (pushdown + IP gate)
    G->>X: local / parallel WinRM / CimSession / EVTX
    X-->>G: raw payloads {Xml, TimeUtc*, RecordId…} + HostResults
    loop every payload
        G->>G: regex pre-filter on raw XML
        G->>P: ConvertTo-WinSecEvent
        P-->>G: typed WinSecEvent (decoded, classified)
        G->>G: CIDR membership / MatchedOn refinement / dedup
    end
    G-->>U: WinSecEvent[] sorted by TimeUtc (+ host statuses)
Loading

The 3-layer match strategy

Layer Where What Why
1 Β· Pushdown event log service channel + EventID set + TimeCreated UTC window; exact single IP: EventData/Data='v' for every textual variant + UserData/EventXML/Address='v' huge logs scan at native speed; time window bounds work (legacy truncated before filtering)
2 Β· Verification client boundary-aware compiled regex over the whole record XML catches mapped/compressed IPv6 renderings the equality gate can't express; CIDR membership via structural field tests
3 Β· DeepScan client same regex, gate disabled IPs inside command lines, UNC paths, task XML β€” thorough sweeps

IDs without IP fields (4740, 4776, 7045, 4698…) always pass through layer 1 ungated β€” contextual evidence survives even though the IP never appears in it. Event Log XPath has no contains(); this design works with the query dialect instead of fighting it.

Remote execution contract

# what actually runs on each target (simplified):
$records = Get-WinEvent -FilterXml ([xml]$queryText) -MaxEvents $max
foreach ($r in $records) {
    @{ Xml = $r.ToXml(); Computer = $r.MachineName; RecordId = $r.RecordId
       TimeUtc = $r.TimeCreated.ToUniversalTime()   # ← computed SOURCE-side
       LogName = $r.LogName; Message = … }
}

Hashtables (not classes) cross the wire β†’ no module needed on targets, CLIXML-safe, parse centrally. ForEach-Object -Parallel fans out with -ThrottleLimit; each host gets retry + stopwatch; results are labeled with the requested name and the resolved COMPUTERNAME. Missing channels surface as Skipped, transport errors as Failed β€” see Remote Collection.

Parser design (ConvertTo-WinSecEvent)

One function, many inputs: EventLogRecord (duck-typed β€” no Windows-only type references at definition time), [xml], raw XML string, or the remote payload hashtable. That single property is what makes parser tests run on Linux CI.

flowchart LR
    IN["record / xml / string / hashtable"] --> SYS["System section<br/>SelectSingleNode local-name()<br/>(namespace-safe, StrictMode-safe)"]
    SYS --> DATA{"EventData?"}
    DATA -- yes --> NAMED["Data[@Name] β†’ hashtable<br/>positional D0..Dn mapped<br/>for 7045/6008/104"]
    DATA -- no --> UD["UserData/EventXML<br/>RCM Param1/2/3 Β· LSM User/Address<br/>+ Correlation ActivityID"]
    NAMED --> MAP["field mapping<br/>IP ports Β· accounts Β· logon type<br/>direction %%14592 Β· protocol IANA"]
    UD --> MAP
    MAP --> CAT["catalog lookup<br/>category Β· importance Β· ATT&CK Β· name"]
    CAT --> OUT2["outcome resolution<br/>static ID sets + status-driven<br/>(4768/4769/4770/4776: 0x0=Success)"]
    OUT2 --> DEC["status decode chain<br/>SubStatus→FailureCode→ResultCode→<br/>ErrorCode→%%Reason→Status"]
    DEC --> EV["WinSecEvent"]
Loading

Decoding chain detail: 4625's Status is generic (0xC000006D) β€” the SubStatus carries the truth (0xC000006A incorrect password). The chain tries the most specific field first per event family, falling back with the raw code preserved. Tables: Reference Tables.

Catalog schema

Resources/EventCatalog.json is the knowledge base β€” profiles, classification, audit requirements and docs all derive from it:

{
  "version": "2.1.0",
  "logs":      { "Security": "Security", "RCM": "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational", "…": "…" },
  "logAliases":{ "RDP": ["…RCM…", "…LSM…"], "Firewall": ["…"], "Sysmon": ["…"] },
  "profiles":  { "BruteForce": { "description": "…", "eventIds": [4624, 4625, "…"] },
                 "IncidentResponse": { "description": "…", "categories": ["Authentication", "…"] } },
  "events": [
    { "Id": 4625, "Log": "Security",
      "Name": "An account failed to log on",
      "Category": "Authentication", "Importance": "Critical",
      "Attack": ["T1110.001", "T1110.003", "T1078"],
      "IpFields": ["IpAddress"],
      "Notes": "Primary brute-force indicator. Decode SubStatus: 0xC000006A bad password, …" }
  ]
}

Integrity is test-enforced: unique Log|Id, valid enum categories/importance, well-formed ATT&CK IDs, profile references resolvable. Human-readable mirror: docs/EventCatalog.md (regenerated by build/Export-CatalogDoc.ps1 β€” never hand-edited).

Adding an event ID = one JSON entry (+ optional profile membership). Nothing else changes β€” parser, query builder and audit matrix pick it up.

Type system

Class Role
WinSecEvent normalized event (see property dictionary); GetDedupKey(), ToHashtable()
WinSecCase investigation container: id, operator, subject IPs, notes, findings, evidence registry
WinSecSession reconstructed logon session with admin/pivot/duration facts
WinSecBruteForceFinding detection result with severity, rates, evidence
WinSecLogCoverage completeness snapshot with risk rating
WinSecIpAttribution enrichment result, offline fields + provider fields
WinSecTimelineBucket one histogram bucket (feeds SVG renderer)
WinSecAuditGap audit-policy deficiency + remediation command
WinSecHostResult per-target execution status

Enums give compile-time-safe vocabulary: WinSecEventCategory (22 values, ATT&CK-tactic aligned), WinSecOutcome, WinSecVerdict, WinSecSeverity, WinSecExportFormat, WinSecDirection, WinSecHostStatus, WinSecLogonType (numeric = wire values).

Configuration & secrets

Typed JSON overlay (defaults βŠ• file), coerced on read and write, corrupt-file tolerant, WINSECHUNTER_CONFIG_DIR relocatable. Secrets: env-var names in config, values never β€” see Configuration.

Platform notes

Surface Windows Linux/macOS
Module import βœ… βœ… (Windows-only cmdlets guard at call time with actionable PlatformNotSupportedException)
Collection / coverage / audit / trace βœ… ❌ guarded
Analysis on exported CLIXML/JSON βœ… βœ…
HTML/CSV/JSON/Markdown export βœ… βœ…
Attribution (PTR + providers) βœ… βœ… (System.Net.Dns + Invoke-RestMethod)

Testing strategy

Layer How Count (v2.1.0)
Pure logic Pester 5, any OS: parser (real-world XML fixtures incl. UserData/Sysmon/7045 positional), decoders, IP math, XPath builder, detection, sessions, timeline, renderer, export+hash verification, config 132
Module contract manifest/exports/help/OutputType/ShouldProcess/no-Write-Host/AST-parse gates in suite
Static analysis PSScriptAnalyzer, Error+Warning = 0 (CI gate; TypeNotFound diagnostic filtered β€” module-scoped classes) CI
Live smoke windows-smoke CI job: build β†’ import β†’ read-only Get-WinSecLogCoverage/catalog/local loopback sweep/audit read on the runner CI
flowchart LR
    PR["PR opened"] --> CI1["ubuntu-latest<br/>PSSA + Pester 132"]
    PR --> CI2["windows-latest<br/>PSSA + Pester"]
    CI1 & CI2 --> SM["windows-smoke<br/>live read-only checks"]
    SM --> M{"merge"}
    TAG["tag v*.*.*"] --> PUB["publish.yml<br/>tests β†’ build β†’ version check β†’ PSGallery"]
Loading

Repository layout

β”œβ”€β”€ src/WinSecHunter/           ← the module (ships as-is)
β”‚   β”œβ”€β”€ WinSecHunter.psd1/.psm1/.Format.ps1xml
β”‚   β”œβ”€β”€ Enums/ Classes/ Private/ Public/ Resources/
β”œβ”€β”€ tests/                      ← Pester 5 suite + TestHelpers.ps1
β”œβ”€β”€ build/                      ← Build-Module Β· Install-Dev Β· validate Β· Export-CatalogDoc
β”œβ”€β”€ docs/                       ← repo-side docs (Architecture, Examples, AuditPolicyGuide, EventCatalog)
β”œβ”€β”€ wiki/                       ← this wiki's source
β”œβ”€β”€ .github/workflows/          ← ci.yml Β· publish.yml
└── README.md Β· CHANGELOG.md Β· LICENSE Β· PSScriptAnalyzerSettings.psd1

2.1.0 execution and reporting model

Per-channel execution. The XPath builder emits one QueryList with a Select per channel. Split-WinSecQueryXml turns it into one single-Select query per channel, and the executor runs them independently. A missing channel therefore produces one Skipped row in WinSecHostResult.LogDetails instead of failing the host. -MaxEvents is a per-channel cap.

Error classification. Get-WinSecLogErrorKind maps HResult/Win32 codes first (5 = access denied, 15007 = channel not found, 2/3 = not found), then falls back to the message. No branch depends on an English OS string.

Time bounds. [datetime]::MinValue is the "not set" sentinel. $null cannot bind to [datetime], and [Nullable[datetime]] is unboxed during parameter binding, so HasValue is not reliable.

Reporting pipeline.

flowchart LR
    E[Events Β· Findings Β· Sessions<br/>Coverage Β· Attribution Β· Timeline] --> C[Get-WinSecCaseContext<br/>verdict Β· severity Β· confidence Β· IOCs]
    C --> S[Get-WinSecReportSection<br/>renderer-agnostic section model]
    S --> M[Markdown renderer]
    S --> H[HTML renderer]
    C --> A[Abuse report context<br/>provider Β· recipient Β· redaction]
    A --> EML[RFC 5322 .eml + CSV/JSON]
Loading

Both renderers consume the same section model, so Markdown and HTML can never disagree about a number or a verdict. Reports are written to <CaseId>-reports, outside the evidence bundle, so the manifest stays verifiable.


Next: Development β†’ Β· Related: Problem Domain Β· ROADMAP

πŸ›‘οΈ WinSecHunter v2.1.0

🏠 Home

πŸ“– Learn

🎯 Problem Domain πŸš€ Getting Started 🧭 Architecture

πŸ”§ Use

πŸ“š Cmdlet Reference πŸ” Detection and Analysis 🌐 Remote Collection πŸ›°οΈ Threat Intel and Attribution πŸ›‘οΈ Audit Policy Guide

πŸ“„ Report and preserve

πŸ“ Reporting and Abuse 🧾 Evidence and Reporting πŸ“Š Reference Tables

βš™οΈ Operate

πŸ”§ Configuration 🩺 Troubleshooting and FAQ πŸ§‘β€πŸ’» Development πŸ—ΊοΈ Roadmap


πŸ“„ README Β· πŸ”– Changelog Β· πŸ“¦ Releases

Clone this wiki locally