Repository navigation
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.
- 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.
- 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.
- Evidence discipline. UTC everywhere for analysis, dual timestamps on every event, per-host accountability, hash manifests on export.
-
Pipeline-native. No
Write-Host/exitin library code; objects in, objects out;ShouldProcesson everything that mutates. - Boring technology. Dot-sourced module layout, JSON resources, no compiled dependencies, no experimental language features β debuggable at 3 a.m. during an incident.
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
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).
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)
| 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.
# 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.
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"]
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.
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.
| 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).
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.
| 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) |
| 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"]
βββ 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
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]
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 Β· PolyForm Noncommercial 1.0.0 Β· derivative of Get-SecurityEventsByIP.ps1 by Mikhail Deynekin
Repository Β· Changelog Β· Security policy Β· Contributing
π Home
π― Problem Domain π Getting Started π§ Architecture
π Cmdlet Reference π Detection and Analysis π Remote Collection π°οΈ Threat Intel and Attribution π‘οΈ Audit Policy Guide
π Reporting and Abuse π§Ύ Evidence and Reporting π Reference Tables
π§ Configuration π©Ί Troubleshooting and FAQ π§βπ» Development πΊοΈ Roadmap