the shed // ruby x devops

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:

eventlog_triage.rb

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.

eventlog_triage.rb — Ruby + win32ole (Windows)
#!/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.

$ ruby eventlog_triage_test.rb # stub harness on Linux — exit code: 0
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
Get the code

Full script, stub test harness + README on GitHub: ruby-devops-toolkit/eventlog-triage

7
event IDs triaged
2
WMI channels
8
assertions in the harness
setup

Prerequisites

you need
  • Windows + Ruby — RubyInstaller builds ship the win32ole stdlib; 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.
diagram

WMI access vs pure-Ruby triage

eventlog_triage.rb architecture: EventSource WMI layer, pure-Ruby Triage, stub harness

the stub harness enters the pipeline where EventSource exits — same plain-hash interface, no Windows required
reference

The full script

eventlog_triage.rbruby
#!/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
walkthrough

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.

eventlog_triage_test.rbruby
#!/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
debug

Troubleshooting

when it surprises you
  • 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 --hours before reaching for indexes.
  • 4625 account shows unknown — some logon types lay out insertion strings differently; extend failed_logon_account with the patterns your environment produces.
  • Honest note: the WMI layer was stub-tested, not executed, in this tutorial’s Linux sandbox — see the walkthrough.
extend

Where to take it

ideas
  • 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 EventSource for a Get-WinEvent JSON bridge to reach modern channels (Sysmon, PowerShell Operational) that Win32_NTLogEvent can’t see.