Someone brute-forced Administrator overnight and a mystery service appeared at 10am. It’s all in the Windows event logs — under ten thousand routine entries. Ruby + WMI turns that into a severity-ranked triage report you can schedule.
Step through the build below:
A Windows box rebooted overnight, a service is flapping, and there’s a burst of failed logons someone should have noticed. The answers are all in the System and Security event logs — buried under thousands of routine entries that make Event Viewer scrolling a punishment detail.
This tutorial builds a Ruby script that pulls the last N hours of both channels through WMI (Win32_NTLogEvent via the win32ole stdlib), buckets the handful of event IDs that actually matter — unexpected shutdowns, account lockouts, failed-logon bursts, service crashes, new service installs — and prints a severity-ranked triage report with Task Scheduler-friendly exit codes.
Because WMI only exists on Windows, the script is deliberately split so the triage logic is pure Ruby — and the output tab shows that logic running against 13 realistic fixture events on Linux, with 8 assertions passing.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# eventlog_triage.rb — triage Windows Event Logs via WMI (win32ole).
#
# Instead of scrolling Event Viewer after something breaks, this script
# pulls the last N hours of System + Security events through WMI
# (Win32_NTLogEvent), buckets the ones that actually matter, and prints
# a severity-ranked triage report:
#
# CRIT 6008 unexpected shutdown (crash/power loss)
# CRIT 4740 account locked out
# CRIT 4625 failed logons >= threshold for one account (spray/brute force)
# WARN 7034 service terminated unexpectedly
# WARN 7045 new service installed (persistence technique — verify it!)
# WARN 4625 failed logons below threshold
# INFO 1074 planned shutdown/restart (who requested it)
#
# Requires Windows + Ruby with the win32ole stdlib (ships with RubyInstaller).
# Run elevated to read the Security log. Text and --json output; exits 2 if
# anything CRIT was found, 1 for WARN, 0 clean — schedulable via Task Scheduler.
#
# Usage (Windows, elevated):
# ruby eventlog_triage.rb --hours 24
# ruby eventlog_triage.rb --hours 6 --logon-threshold 10 --json
#
# The triage logic is separated from WMI access (EventSource vs Triage)
# so it can be unit-tested on any OS with a stub source — see
# eventlog_triage_test.rb in this folder.
require 'json'
require 'optparse'
require 'time'
# --------------------------------------------------------------------------
# WMI access layer. The ONLY thing that touches win32ole. Everything below
# it works on plain hashes, which is what makes the logic testable off-Windows.
# --------------------------------------------------------------------------
class EventSource
def initialize
require 'win32ole'
@wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\cimv2')
end
# WMI wants dates in DMTF format: 20260824093000.000000+000
def dmtf(time)
time.utc.strftime('%Y%m%d%H%M%S.000000+000')
end
# Returns an array of plain hashes — one per event.
def events_since(cutoff)
query = "SELECT LogFile, EventCode, TimeGenerated, SourceName, Message, InsertionStrings " \
"FROM Win32_NTLogEvent WHERE (LogFile='System' OR LogFile='Security') " \
"AND TimeGenerated >= '#{dmtf(cutoff)}'"
@wmi.ExecQuery(query).each.map do |e|
{
'log' => e.LogFile,
'code' => e.EventCode.to_i,
'time' => e.TimeGenerated.to_s,
'source' => e.SourceName.to_s,
'message' => e.Message.to_s,
'strings' => (e.InsertionStrings || []).to_a.map(&:to_s)
}
end
end
end
# --------------------------------------------------------------------------
# Triage logic — pure Ruby, no WMI. Feed it hashes, get findings back.
# --------------------------------------------------------------------------
class Triage
SEV = { 'CRIT' => 2, 'WARN' => 1, 'INFO' => 0 }.freeze
def initialize(logon_threshold: 5)
@logon_threshold = logon_threshold
end
# For 4625 the target account name is insertion string index 5 in the
# standard Security template; fall back to scraping the message text.
def failed_logon_account(ev)
acct = ev['strings'][5] if ev['strings'] && ev['strings'].size > 5
acct = ev['message'][/Account Name:\s+(\S+)/, 1] if acct.nil? || acct.empty?
acct || 'unknown'
end
def run(events)
findings = []
failed_by_account = Hash.new(0)
events.each do |ev|
case ev['code']
when 6008
findings << ['CRIT', "unexpected shutdown at #{ev['time']} — crash or power loss (System/6008)"]
when 4740
findings << ['CRIT', "account lockout: #{ev['strings']&.first || 'unknown'} (Security/4740)"]
when 7034
findings << ['WARN', "service terminated unexpectedly: #{ev['strings']&.first || ev['source']} (System/7034)"]
when 7045
svc = ev['strings']&.first || 'unknown'
findings << ['WARN', "NEW service installed: #{svc} — verify this was intentional (System/7045)"]
when 4625
failed_by_account[failed_logon_account(ev)] += 1
when 1074
who = ev['strings'] ? ev['strings'][6] || ev['strings'][0] : 'unknown'
findings << ['INFO', "planned shutdown/restart requested by #{who} (System/1074)"]
end
end
failed_by_account.each do |acct, count|
if count >= @logon_threshold
findings << ['CRIT', "#{count} failed logons for account '#{acct}' — possible brute force (Security/4625)"]
else
findings << ['WARN', "#{count} failed logon(s) for account '#{acct}' (Security/4625)"]
end
end
findings.sort_by { |sev, _| -SEV[sev] }
end
end
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
options = { hours: 24, logon_threshold: 5, json: false }
OptionParser.new do |o|
o.banner = 'Usage: eventlog_triage.rb [options] (Windows, run elevated)'
o.on('--hours N', Integer, 'Look back N hours (default 24)') { |v| options[:hours] = v }
o.on('--logon-threshold N', Integer, 'CRIT when failed logons/account >= N (default 5)') { |v| options[:logon_threshold] = v }
o.on('--json', 'Emit JSON instead of text') { options[:json] = true }
end.parse!
unless RUBY_PLATFORM =~ /mingw|mswin|cygwin/
abort 'eventlog_triage: this script queries WMI and must run on Windows. ' \
'On other platforms, run eventlog_triage_test.rb to exercise the triage logic.'
end
cutoff = Time.now - options[:hours] * 3600
events = EventSource.new.events_since(cutoff)
findings = Triage.new(logon_threshold: options[:logon_threshold]).run(events)
worst = findings.map { |sev, _| Triage::SEV[sev] }.max || 0
if options[:json]
puts JSON.pretty_generate(
'generated_at' => Time.now.iso8601,
'window_hours' => options[:hours],
'events_scanned' => events.size,
'status' => %w[OK WARN CRIT][worst],
'findings' => findings.map { |sev, msg| { 'severity' => sev, 'message' => msg } }
)
else
puts "event log triage — last #{options[:hours]}h — #{events.size} events scanned [#{%w[OK WARN CRIT][worst]}]"
findings.each { |sev, msg| puts format(' [%-4s] %s', sev, msg) }
puts ' nothing noteworthy — quiet logs are happy logs' if findings.empty?
end
exit(worst == 2 ? 2 : worst == 1 ? 1 : 0)
end
One class touches WMI. EventSource connects to winmgmts:\\.\root\cimv2, runs a single ExecQuery over Win32_NTLogEvent with a DMTF-formatted time cutoff (yyyymmddHHMMSS.000000+000 — WMI’s own date dialect), and immediately flattens every COM object into a plain Ruby hash.
Everything else is pure Ruby. Triage never sees a COM object. 6008/4740/7034/7045/1074 map straight to findings; 4625 failed logons aggregate per target account — insertion string index 5 in the standard Security template, with a message-scrape fallback — and per-account counts convert to CRIT (≥ threshold, likely spray) or WARN. Findings sort CRIT-first.
The split is the testability story. Because Triage consumes plain hashes, a stub harness on any OS can feed it fixtures shaped exactly like EventSource output and assert on the classifications. That’s what ran in the sandbox for this post — honest testing for code whose real API only exists on Windows.
stub harness: 13 fixture events -> 8 findings [CRIT] unexpected shutdown at 20260824T031502 — crash or power loss (System/6008) [CRIT] account lockout: svc_backup (Security/4740) [CRIT] 6 failed logons for account 'Administrator' — possible brute force (Security/4625) [WARN] service terminated unexpectedly: Print Spooler (System/7034) [WARN] service terminated unexpectedly: Print Spooler (System/7034) [WARN] NEW service installed: UpdaterSvc — verify this was intentional (System/7045) [WARN] 1 failed logon(s) for account 'jsmith' (Security/4625) [INFO] planned shutdown/restart requested by HOST01\svc_deploy (System/1074) PASS 6008 classified CRIT PASS 4740 lockout names svc_backup PASS Administrator spray is CRIT PASS jsmith single failure is WARN PASS 7034 spooler crash reported x2 PASS 7045 new service flagged PASS 1074 restart is INFO PASS CRIT findings sort first ALL 8 ASSERTIONS PASSED
Full script, stub test harness + README on GitHub: ruby-devops-toolkit/eventlog-triage
Prerequisites
- Windows + Ruby — RubyInstaller builds ship the
win32olestdlib; no gems. - An elevated prompt — reading the Security log requires admin rights. Non-admin can usually read System only.
- Failure auditing enabled if you want 4625s:
secpol.msc→ Audit Policy → Audit logon events → Failure. - Any OS for the stub harness — that part is deliberately Windows-free.
WMI access vs pure-Ruby triage
The full script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# eventlog_triage.rb — triage Windows Event Logs via WMI (win32ole).
#
# Instead of scrolling Event Viewer after something breaks, this script
# pulls the last N hours of System + Security events through WMI
# (Win32_NTLogEvent), buckets the ones that actually matter, and prints
# a severity-ranked triage report:
#
# CRIT 6008 unexpected shutdown (crash/power loss)
# CRIT 4740 account locked out
# CRIT 4625 failed logons >= threshold for one account (spray/brute force)
# WARN 7034 service terminated unexpectedly
# WARN 7045 new service installed (persistence technique — verify it!)
# WARN 4625 failed logons below threshold
# INFO 1074 planned shutdown/restart (who requested it)
#
# Requires Windows + Ruby with the win32ole stdlib (ships with RubyInstaller).
# Run elevated to read the Security log. Text and --json output; exits 2 if
# anything CRIT was found, 1 for WARN, 0 clean — schedulable via Task Scheduler.
#
# Usage (Windows, elevated):
# ruby eventlog_triage.rb --hours 24
# ruby eventlog_triage.rb --hours 6 --logon-threshold 10 --json
#
# The triage logic is separated from WMI access (EventSource vs Triage)
# so it can be unit-tested on any OS with a stub source — see
# eventlog_triage_test.rb in this folder.
require 'json'
require 'optparse'
require 'time'
# --------------------------------------------------------------------------
# WMI access layer. The ONLY thing that touches win32ole. Everything below
# it works on plain hashes, which is what makes the logic testable off-Windows.
# --------------------------------------------------------------------------
class EventSource
def initialize
require 'win32ole'
@wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\cimv2')
end
# WMI wants dates in DMTF format: 20260824093000.000000+000
def dmtf(time)
time.utc.strftime('%Y%m%d%H%M%S.000000+000')
end
# Returns an array of plain hashes — one per event.
def events_since(cutoff)
query = "SELECT LogFile, EventCode, TimeGenerated, SourceName, Message, InsertionStrings " \
"FROM Win32_NTLogEvent WHERE (LogFile='System' OR LogFile='Security') " \
"AND TimeGenerated >= '#{dmtf(cutoff)}'"
@wmi.ExecQuery(query).each.map do |e|
{
'log' => e.LogFile,
'code' => e.EventCode.to_i,
'time' => e.TimeGenerated.to_s,
'source' => e.SourceName.to_s,
'message' => e.Message.to_s,
'strings' => (e.InsertionStrings || []).to_a.map(&:to_s)
}
end
end
end
# --------------------------------------------------------------------------
# Triage logic — pure Ruby, no WMI. Feed it hashes, get findings back.
# --------------------------------------------------------------------------
class Triage
SEV = { 'CRIT' => 2, 'WARN' => 1, 'INFO' => 0 }.freeze
def initialize(logon_threshold: 5)
@logon_threshold = logon_threshold
end
# For 4625 the target account name is insertion string index 5 in the
# standard Security template; fall back to scraping the message text.
def failed_logon_account(ev)
acct = ev['strings'][5] if ev['strings'] && ev['strings'].size > 5
acct = ev['message'][/Account Name:\s+(\S+)/, 1] if acct.nil? || acct.empty?
acct || 'unknown'
end
def run(events)
findings = []
failed_by_account = Hash.new(0)
events.each do |ev|
case ev['code']
when 6008
findings << ['CRIT', "unexpected shutdown at #{ev['time']} — crash or power loss (System/6008)"]
when 4740
findings << ['CRIT', "account lockout: #{ev['strings']&.first || 'unknown'} (Security/4740)"]
when 7034
findings << ['WARN', "service terminated unexpectedly: #{ev['strings']&.first || ev['source']} (System/7034)"]
when 7045
svc = ev['strings']&.first || 'unknown'
findings << ['WARN', "NEW service installed: #{svc} — verify this was intentional (System/7045)"]
when 4625
failed_by_account[failed_logon_account(ev)] += 1
when 1074
who = ev['strings'] ? ev['strings'][6] || ev['strings'][0] : 'unknown'
findings << ['INFO', "planned shutdown/restart requested by #{who} (System/1074)"]
end
end
failed_by_account.each do |acct, count|
if count >= @logon_threshold
findings << ['CRIT', "#{count} failed logons for account '#{acct}' — possible brute force (Security/4625)"]
else
findings << ['WARN', "#{count} failed logon(s) for account '#{acct}' (Security/4625)"]
end
end
findings.sort_by { |sev, _| -SEV[sev] }
end
end
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
options = { hours: 24, logon_threshold: 5, json: false }
OptionParser.new do |o|
o.banner = 'Usage: eventlog_triage.rb [options] (Windows, run elevated)'
o.on('--hours N', Integer, 'Look back N hours (default 24)') { |v| options[:hours] = v }
o.on('--logon-threshold N', Integer, 'CRIT when failed logons/account >= N (default 5)') { |v| options[:logon_threshold] = v }
o.on('--json', 'Emit JSON instead of text') { options[:json] = true }
end.parse!
unless RUBY_PLATFORM =~ /mingw|mswin|cygwin/
abort 'eventlog_triage: this script queries WMI and must run on Windows. ' \
'On other platforms, run eventlog_triage_test.rb to exercise the triage logic.'
end
cutoff = Time.now - options[:hours] * 3600
events = EventSource.new.events_since(cutoff)
findings = Triage.new(logon_threshold: options[:logon_threshold]).run(events)
worst = findings.map { |sev, _| Triage::SEV[sev] }.max || 0
if options[:json]
puts JSON.pretty_generate(
'generated_at' => Time.now.iso8601,
'window_hours' => options[:hours],
'events_scanned' => events.size,
'status' => %w[OK WARN CRIT][worst],
'findings' => findings.map { |sev, msg| { 'severity' => sev, 'message' => msg } }
)
else
puts "event log triage — last #{options[:hours]}h — #{events.size} events scanned [#{%w[OK WARN CRIT][worst]}]"
findings.each { |sev, msg| puts format(' [%-4s] %s', sev, msg) }
puts ' nothing noteworthy — quiet logs are happy logs' if findings.empty?
end
exit(worst == 2 ? 2 : worst == 1 ? 1 : 0)
end
Step by step
1. The WMI query and DMTF dates
WMI’s WQL dialect wants timestamps in DMTF format — 20260824093000.000000+000 — so EventSource#dmtf renders the cutoff in UTC and the query filters TimeGenerated server-side. One query covers both channels (LogFile='System' OR LogFile='Security'); filtering in WQL instead of Ruby matters when the Security log holds a million events.
2. Flattening COM objects immediately
The very first thing done with each Win32_NTLogEvent COM object is conversion to a plain hash of strings and integers. COM objects are live references into WMI; hashes are just data. Every line of logic downstream becomes portable, testable, and immune to WIN32OLERuntimeError surprises mid-analysis.
3. Triage rules and the 4625 aggregation
Most rules are direct: 6008 unexpected shutdown and 4740 lockout are CRIT on sight; 7034 service crash and 7045 new service installed (a classic persistence move — verify every one) are WARN; 1074 records who requested a planned restart. Failed logons are different: one 4625 is noise, thirty against one account is an attack — so they aggregate per account, and the count crosses --logon-threshold into CRIT.
4. The stub harness (and an honest limitation)
This script’s real API exists only on Windows, and this tutorial was built in a Linux sandbox. So what was actually executed and verified here is eventlog_triage_test.rb: 13 realistic fixture events — crash, lockout, a 6-strong logon burst, double service crash, service install, planned restart — driven through Triage, with 8 assertions on the classifications, all passing. The EventSource layer follows Microsoft’s documented Win32_NTLogEvent schema but should be treated as verified-by-documentation, not by execution. Run the real thing on a Windows host, elevated.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# eventlog_triage_test.rb — stub test harness for the Triage class.
#
# WMI (win32ole) only exists on Windows, so this harness feeds the Triage
# class realistic Win32_NTLogEvent fixtures as plain hashes — exactly the
# shape EventSource#events_since produces — and asserts the classifications.
# Runs on any OS: ruby eventlog_triage_test.rb
require_relative 'eventlog_triage'
FIXTURES = [
# unexpected shutdown
{ 'log' => 'System', 'code' => 6008, 'time' => '20260824T031502',
'source' => 'EventLog', 'message' => 'The previous system shutdown was unexpected.', 'strings' => [] },
# service crash x2 (Spooler)
{ 'log' => 'System', 'code' => 7034, 'time' => '20260824T031604',
'source' => 'Service Control Manager', 'message' => 'The Print Spooler service terminated unexpectedly.',
'strings' => ['Print Spooler', '1'] },
{ 'log' => 'System', 'code' => 7034, 'time' => '20260824T041604',
'source' => 'Service Control Manager', 'message' => 'The Print Spooler service terminated unexpectedly.',
'strings' => ['Print Spooler', '2'] },
# new service installed
{ 'log' => 'System', 'code' => 7045, 'time' => '20260824T100000',
'source' => 'Service Control Manager', 'message' => 'A service was installed in the system.',
'strings' => ['UpdaterSvc', '%SystemRoot%\\Temp\\updater.exe', 'user mode service', 'auto start', 'LocalSystem'] },
# planned restart
{ 'log' => 'System', 'code' => 1074, 'time' => '20260824T110000',
'source' => 'User32', 'message' => 'The process msiexec.exe has initiated a restart.',
'strings' => ['msiexec.exe', 'HOST01', 'Operating System: Service pack', '0x80020010', 'restart', '', 'HOST01\\svc_deploy'] },
# 6 failed logons for Administrator (>= default threshold 5 -> CRIT)
*6.times.map do |i|
{ 'log' => 'Security', 'code' => 4625, 'time' => "20260824T12000#{i}",
'source' => 'Microsoft-Windows-Security-Auditing',
'message' => 'An account failed to log on. Account Name: Administrator',
'strings' => ['S-1-0-0', '-', '-', '0x0', 'S-1-0-0', 'Administrator', 'HOST01', '0xC000006D'] }
end,
# 1 failed logon for jsmith (below threshold -> WARN)
{ 'log' => 'Security', 'code' => 4625, 'time' => '20260824T121500',
'source' => 'Microsoft-Windows-Security-Auditing',
'message' => 'An account failed to log on. Account Name: jsmith',
'strings' => ['S-1-0-0', '-', '-', '0x0', 'S-1-0-0', 'jsmith', 'HOST01', '0xC000006A'] },
# account lockout
{ 'log' => 'Security', 'code' => 4740, 'time' => '20260824T121600',
'source' => 'Microsoft-Windows-Security-Auditing',
'message' => 'A user account was locked out.', 'strings' => ['svc_backup', 'HOST01'] }
].freeze
findings = Triage.new(logon_threshold: 5).run(FIXTURES)
puts "stub harness: #{FIXTURES.size} fixture events -> #{findings.size} findings"
findings.each { |sev, msg| puts format(' [%-4s] %s', sev, msg) }
puts
# ---- assertions -----------------------------------------------------------
fails = 0
def assert(desc, cond, fails)
if cond
puts " PASS #{desc}"
fails
else
puts " FAIL #{desc}"
fails + 1
end
end
sevs = findings.map(&:first)
msgs = findings.map(&:last)
fails = assert('6008 classified CRIT', msgs.any? { |m| m.include?('unexpected shutdown') }, fails)
fails = assert('4740 lockout names svc_backup', msgs.any? { |m| m.include?("lockout: svc_backup") }, fails)
fails = assert('Administrator spray is CRIT', findings.any? { |s, m| s == 'CRIT' if m.include?("'Administrator'") }, fails)
fails = assert('jsmith single failure is WARN', findings.any? { |s, m| s == 'WARN' if m.include?("'jsmith'") }, fails)
fails = assert('7034 spooler crash reported x2', msgs.count { |m| m.include?('Print Spooler') } == 2, fails)
fails = assert('7045 new service flagged', msgs.any? { |m| m.include?('UpdaterSvc') }, fails)
fails = assert('1074 restart is INFO', findings.any? { |s, m| s == 'INFO' if m.include?('msiexec') || m.include?('svc_deploy') }, fails)
fails = assert('CRIT findings sort first', sevs.first == 'CRIT', fails)
puts
if fails.zero?
puts "ALL #{8} ASSERTIONS PASSED"
exit 0
else
puts "#{fails} ASSERTION(S) FAILED"
exit 1
end
Troubleshooting
WIN32OLERuntimeError: Access is denied— you’re not elevated. Run as Administrator; Security-log reads need it.- Empty Security results — failure auditing may be off (see prerequisites) or the log rolled over. Check Event Viewer → Windows Logs → Security.
- Slow queries — the cutoff filters server-side, but a multi-GB Security log still grinds; shrink
--hoursbefore reaching for indexes. - 4625 account shows
unknown— some logon types lay out insertion strings differently; extendfailed_logon_accountwith the patterns your environment produces. - Honest note: the WMI layer was stub-tested, not executed, in this tutorial’s Linux sandbox — see the walkthrough.
Where to take it
- Add 4720 (user created), 4732 (added to admin group), and 1102 (audit log cleared — the loudest red flag there is).
- Correlate 7045 new services with 4625 bursts in the same window — that pairing is an incident, not two findings.
- Run it from Task Scheduler every 15 minutes; exit code 2 triggers a webhook. Congratulations, you have a free tier-0 SIEM.
- Swap
EventSourcefor aGet-WinEventJSON bridge to reach modern channels (Sysmon, PowerShell Operational) thatWin32_NTLogEventcan’t see.