Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

The Problem

Something changes in /etc, a web root, or a config directory, and nobody notices until it breaks something — or until it turns out to be the first sign of an intrusion. Full file-integrity monitoring products (Tripwire, AIDE, osquery) are the right answer at scale, but they’re also a real install and a real config format to learn. Often what you actually want on a single box, or as a lightweight check baked into a deploy pipeline, is much simpler: “tell me if anything under this directory changed since I last looked.”

Ruby’s standard library already has everything needed for this: Find to walk a directory tree, Digest::SHA256 to fingerprint file contents, and JSON to persist a baseline. No gems, no daemon, no agent — just a script you can run by hand, from cron, or as a CI step.

Prerequisites

  • Ruby 3.x — find, digest, and json are all part of the standard library, nothing to install
  • Read access to the directory tree you want to monitor
  • Works on Linux, macOS, and Windows — the checksum and diff logic has no OS-specific dependency

How It Works

The script has two commands that share the same snapshot logic: baseline captures a point-in-time fingerprint of a directory tree, and check takes a fresh fingerprint and diffs it against that baseline.

File integrity monitoring workflow: a directory tree is walked into a baseline.json via Snapshot#build, then compared on demand against a fresh snapshot using the pure Diff.compare function

The full script

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# file_integrity_monitor.rb — Baseline a directory tree's checksums and
# later detect any file that was added, removed, or modified.
#
# Usage:
#   ruby file_integrity_monitor.rb baseline /etc baseline.json
#   ruby file_integrity_monitor.rb baseline /etc baseline.json --exclude "*.log,*.tmp"
#   ruby file_integrity_monitor.rb check /etc baseline.json
#   ruby file_integrity_monitor.rb check /etc baseline.json --json drift.json
#
# Requires: Ruby 3.x only — 'find', 'digest', and 'json' are all stdlib,
# nothing to install. Works on Linux, macOS, and Windows.

require 'find'
require 'digest'
require 'json'
require 'optparse'
require 'time'

# ---------------------------------------------------------------------------
# Snapshot — walks a directory tree and builds a Hash of
# relative_path => { size, mtime, sha256 }. This is the only piece that
# touches the filesystem, which keeps the diffing logic below pure and easy
# to test with hand-built snapshots.
# ---------------------------------------------------------------------------
class Snapshot
  def initialize(root, excludes: [])
    @root = File.expand_path(root)
    @excludes = excludes
  end

  # Returns { "relative/path" => { "size" => n, "mtime" => iso8601, "sha256" => hex } }
  def build
    entries = {}
    Find.find(@root) do |path|
      next if File.directory?(path)

      rel = path.sub(/\A#{Regexp.escape(@root)}\/?/, '')
      next if excluded?(rel)

      begin
        stat = File.stat(path)
        entries[rel] = {
          'size' => stat.size,
          'mtime' => stat.mtime.utc.iso8601,
          'sha256' => Digest::SHA256.file(path).hexdigest
        }
      rescue Errno::EACCES, Errno::ENOENT
        # Unreadable or vanished mid-walk (log rotation, sockets, etc.) —
        # skip rather than aborting the whole baseline.
        next
      end
    end
    entries
  end

  private

  def excluded?(rel)
    @excludes.any? { |pattern| File.fnmatch(pattern, File.basename(rel)) || File.fnmatch(pattern, rel) }
  end
end

# ---------------------------------------------------------------------------
# Diff — pure comparison logic between two snapshots (old "baseline" and new
# "current"). No filesystem access here at all, which is what makes it
# possible to unit test every branch (added/removed/modified/unchanged)
# with plain hashes instead of real files.
# ---------------------------------------------------------------------------
module Diff
  Change = Struct.new(:path, :kind, :detail) # kind: :added, :removed, :modified

  def self.compare(baseline, current)
    changes = []

    (current.keys - baseline.keys).sort.each do |path|
      changes << Change.new(path, :added, "new file, #{current[path]['size']} bytes")
    end

    (baseline.keys - current.keys).sort.each do |path|
      changes << Change.new(path, :removed, "was #{baseline[path]['size']} bytes")
    end

    (baseline.keys & current.keys).sort.each do |path|
      old_e = baseline[path]
      new_e = current[path]
      next if old_e['sha256'] == new_e['sha256']

      changes << Change.new(
        path, :modified,
        "sha256 #{old_e['sha256'][0, 12]}... -> #{new_e['sha256'][0, 12]}..., " \
        "size #{old_e['size']} -> #{new_e['size']}"
      )
    end

    changes
  end
end

# ---------------------------------------------------------------------------
# Report — formatting only, no I/O beyond the JSON string it returns.
# ---------------------------------------------------------------------------
module Report
  def self.print(changes, root)
    if changes.empty?
      puts "No drift detected under #{root}."
      return
    end

    by_kind = changes.group_by(&:kind)
    %i[added removed modified].each do |kind|
      list = by_kind[kind] || []
      next if list.empty?

      puts "\n#{kind.upcase} (#{list.size}):"
      list.each { |c| puts "  #{c.path} — #{c.detail}" }
    end
    puts "\n#{changes.size} total change(s)."
  end

  def self.to_json(changes)
    JSON.pretty_generate(changes.map { |c| { path: c.path, kind: c.kind, detail: c.detail } })
  end
end

# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
  command = ARGV.shift
  options = { excludes: [], json: nil }
  OptionParser.new do |opts|
    opts.on('--exclude PATTERNS', 'Comma-separated fnmatch patterns to skip') { |v| options[:excludes] = v.split(',') }
    opts.on('--json PATH', 'Write machine-readable drift report to PATH (check only)') { |v| options[:json] = v }
  end.parse!(ARGV)

  root = ARGV.shift
  store_path = ARGV.shift

  if command.nil? || root.nil? || store_path.nil?
    warn 'Usage: file_integrity_monitor.rb [baseline|check] DIRECTORY snapshot.json [options]'
    exit 1
  end

  case command
  when 'baseline'
    snapshot = Snapshot.new(root, excludes: options[:excludes]).build
    File.write(store_path, JSON.pretty_generate('root' => File.expand_path(root), 'taken_at' => Time.now.utc.iso8601, 'files' => snapshot))
    puts "Baseline captured: #{snapshot.size} file(s) under #{root} -> #{store_path}"

  when 'check'
    unless File.exist?(store_path)
      warn "No baseline found at #{store_path}. Run the 'baseline' command first."
      exit 1
    end

    stored = JSON.parse(File.read(store_path))
    baseline_files = stored['files']
    current_files = Snapshot.new(root, excludes: options[:excludes]).build

    changes = Diff.compare(baseline_files, current_files)
    Report.print(changes, root)
    File.write(options[:json], Report.to_json(changes)) if options[:json]

    exit(changes.empty? ? 0 : 1)

  else
    warn 'Usage: file_integrity_monitor.rb [baseline|check] DIRECTORY snapshot.json [options]'
    exit 1
  end
end

Walkthrough

Snapshot#build is the only class that touches disk. It walks the tree with the standard library’s Find.find, skips directories, applies the exclude patterns (checked against both the basename and the full relative path, so you can exclude either *.log anywhere or a specific cache/ subtree), and records size, modification time, and a SHA-256 digest per file. The rescue around Errno::EACCES/Errno::ENOENT matters in practice — sockets, broken symlinks, and files that get rotated out from under you mid-walk are normal on a live system, and one unreadable file shouldn’t abort an entire baseline run.

Diff.compare is where the actual detection logic lives, and it’s written as a pure function: two hashes in, an array of Change structs out, with zero filesystem access. Added files are keys present in the current snapshot but not the baseline; removed files are the reverse; modified files are keys present in both whose SHA-256 no longer matches. Because this function never touches disk, it’s the easiest part of the script to test exhaustively — you can construct baseline and current hashes by hand and assert on every branch without creating a single real file.

The CLI layer is thin by design. baseline writes a snapshot plus metadata (root path, capture timestamp) to a JSON file; check loads that file, takes a fresh snapshot, and hands both to Diff.compare. The exit code is 0 when nothing changed and 1 when drift was found, which is exactly what you want for wiring this into a cron job (mail on nonzero) or a CI step (fail the build on nonzero).

Example Output

Real output from a test run — three files seeded into a directory, a baseline taken, then one file modified, one added, one removed, and one deliberately excluded change ignored:

$ ruby file_integrity_monitor.rb baseline /tmp/fim_test baseline.json --exclude "*.log"
Baseline captured: 2 file(s) under /tmp/fim_test -> baseline.json

$ ruby file_integrity_monitor.rb check /tmp/fim_test baseline.json --exclude "*.log"
No drift detected under /tmp/fim_test.

# ... app.conf edited, new.txt added, subdir/keep.txt deleted, app.log edited (excluded) ...

$ ruby file_integrity_monitor.rb check /tmp/fim_test baseline.json --exclude "*.log" --json drift.json

ADDED (1):
  new.txt — new file, 17 bytes

REMOVED (1):
  subdir/keep.txt — was 7 bytes

MODIFIED (1):
  app.conf — sha256 fe03f671e13e... -> 7f48f46b5b78..., size 17 -> 27

3 total change(s).

Note that app.log was also modified in that test run but never appears in the report — the --exclude "*.log" pattern correctly filtered it out of both the baseline and the check snapshot.

Troubleshooting

  • Baseline run is slow on a huge tree — SHA-256 over every byte of every file is the expensive part. For very large trees, consider hashing only the first N KB plus size/mtime as a cheap first pass, and falling back to a full hash only when that cheap signature changes.
  • Every file shows as “modified” after a deploy that didn’t change content — you’re comparing mtime somewhere in your own tooling on top of this script; the script itself only flags a change when the SHA-256 differs, so a touch-only mtime bump won’t trigger a false positive here. If you see this, check whether your deploy process is regenerating files with different (but semantically identical) content, e.g. timestamps embedded in generated files.
  • “No baseline found” on check — the path passed to check must match exactly what was passed to baseline; the script doesn’t infer a default location.
  • Permission errors during baseline — run as a user with read access to the whole tree, or accept that unreadable files are silently skipped (by design, so one locked-down file doesn’t block monitoring everything else).
  • Testing detection logic without real filesDiff.compare takes plain hashes, so you can write a test that hand-builds a baseline hash and a current hash differing in exactly the way you want to verify, with no filesystem fixtures required.

Extending This Script

  • Scheduled checks with alerting — wire check --json into the mailer pattern from this series’ event-log tutorial so drift shows up as an email instead of requiring someone to read cron output.
  • Signed baselines — HMAC-sign the baseline JSON with a key stored outside the monitored tree, so an attacker who can modify files can’t also quietly update the baseline to hide their tracks.
  • Permission and ownership tracking — extend the snapshot to include stat.mode and stat.uid/stat.gid, and flag permission changes as their own category of drift, separate from content changes.
  • Multiple roots in one run — accept a list of directories and merge their snapshots into a single baseline file, so one scheduled job can watch /etc, a web root, and a secrets directory together.
  • Git-aware exclusions — for source directories, reuse the repository’s own .gitignore patterns as the exclude list instead of maintaining a second, parallel set of patterns.

Leave a Reply

Your email address will not be published. Required fields are marked *

Click to access the login or register cheese