Build a File Integrity Monitor with Bash

Build a CLI security tool that detects file tampering using SHA-256 hashes.

Introduction

30 Second Summary

Think about all the files on your computer. How would you know if one of them was quietly changed, deleted, or replaced?

In this project, you will build a File Integrity Monitor using Bash that detects when files are tampered with, added, or deleted. It uses SHA-256 hashes to create file fingerprints and flags changes with color-coded terminal alerts.

What You'll Build

Run your finished tool against any directory on your Mac and it instantly flags modified, added, and deleted files with color-coded terminal alerts.

By the end of this project, you'll have:

  • A command-line security tool that fingerprints files using SHA-256 hashes and catches any file that was modified, added, or deleted since the last scan.
  • Color-coded terminal output that makes changes impossible to miss. Red for deletions, yellow for modifications and new files, and green when everything is intact.
  • A polished CLI experience with a help menu, input validation, and a summary report that gives you a threat count at a glance.
  • Secret Mission: Add recursive subdirectory scanning and a timestamped audit log to handle real-world directory structures.

Are there any prerequisites?

You need a Mac with Homebrew installed and basic comfort navigating the terminal. The project uses Vim for editing and ShellCheck for catching scripting mistakes. Both are covered in Step 1.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Toolkit

You've committed to building a File Integrity Monitor that detects file tampering using SHA-256 hashes. Before writing any code, you need to confirm your tools are ready and set up test files to monitor.

Your Mac already ships with Bash, Vim, and shasum. The one missing piece is ShellCheck, a linter that catches scripting mistakes from the command line.

In this step, get ready to:
  • Install ShellCheck and confirm your scripting tools are ready.
  • Create a project directory with test files to monitor.
  • Confirm Bash and shasum produce the expected output.
Install ShellCheck and verify it works
  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.
  • Check if ShellCheck is already on your machine by running this command:
shellcheck --version

✔️ I see a version number

ShellCheck is already installed. Skip ahead to the Vim check below.

ⓧ Command not found

  • Install ShellCheck using Homebrew by running this command:
brew install shellcheck

Homebrew downloads and installs the package. This takes about a minute.

  • Confirm the install succeeded by running:
shellcheck --version

You should see a version number like 0.11.0 printed to the terminal.

Still seeing 'command not found'?

  • Make sure Homebrew itself is installed. Run brew --version to check.
  • Try closing and reopening Terminal, then run shellcheck --version again.
  • Help me troubleshoot ShellCheck installation
  • Confirm Vim is available by running this command:
vim --version

You should see Vim's version information printed to the terminal. Vim ships pre-installed on macOS.

Why Vim for this project?

In cybersecurity work, you frequently SSH into remote servers that only have Vim (or Vi) available. Using Vim for this entire project builds practical muscle memory with a terminal-native editor.

Create the project directory and test files

Your File Integrity Monitor needs files to watch. You'll create a project folder with a subdirectory of dummy files that simulate sensitive data.

  • Create the project directory and its watched_files subdirectory, then move into the project by running these commands:
mkdir -p ~/fim-project/watched_files
cd ~/fim-project
  • Verify the directory was created by running:
ls

You should see watched_files listed in the output.

  • Create three test files with dummy content by running these commands:
cat > watched_files/passwords.txt << 'EOF'
admin=supersecret123
root=hunter2
guest=welcome1
EOF

cat > watched_files/config.ini << 'EOF'
[server]
host=192.168.1.100
port=8080
debug=false
EOF

cat > watched_files/notes.txt << 'EOF'
Meeting notes from Monday
- Review deployment schedule
- Update access permissions
EOF

What are these test files?

These files simulate the kind of sensitive data you might find on a real server: credentials, configuration, and internal notes. Your File Integrity Monitor will fingerprint each of these files and alert you if anything changes.

  • Confirm all three files exist by running:
ls watched_files/

You should see config.ini, notes.txt, and passwords.txt listed.

Don't see all three files?

  • Make sure you are inside the ~/fim-project directory. Run pwd to check your current location.
  • If a file is missing, re-run the cat command for that file. Make sure you include the EOF closing line on its own line with no spaces.
  • Help me fix my test files
Verify Bash and shasum are available
  • Confirm Bash is available by running:
bash --version

You should see version information that includes 3.2. This is the version of Bash that ships with macOS.

Why is macOS using an older Bash?

macOS ships with Bash 3.2 because newer versions changed to a license that Apple does not bundle. Every feature your File Integrity Monitor uses works perfectly in Bash 3.2.

Your Mac's default interactive shell is zsh, but scripts with #!/bin/bash at the top always run in Bash regardless of your default shell.

The last tool to confirm is shasum, which generates SHA-256 hashes. Before you run the next command, what do you think the output of hashing a file will look like?

  • Test shasum by hashing one of your test files:
shasum -a 256 ./watched_files/passwords.txt

You should see a 64-character hex string followed by two spaces and the filename. This hex string is the file's SHA-256 fingerprint.

What is a SHA-256 hash?

A SHA-256 hash is a one-way fingerprint of a file's contents. Even changing a single character in the file produces a completely different 64-character hash.

Your File Integrity Monitor will use this property to detect tampering. If a file's current hash doesn't match its stored hash, something changed.

Seeing 'No such file or directory'?

  • Make sure you are in the ~/fim-project directory. Run cd ~/fim-project to get back.
  • Verify passwords.txt exists by running ls watched_files/.
  • Help me fix my shasum command

Your toolkit is locked and loaded. Next up, you'll write your first Bash script that loops through every file in watched_files and hashes them all at once.

Hash Every File in a Directory

Your toolkit is ready and your test files are waiting. Now it's time to write your first Bash script.

Integrity monitoring starts with a unique fingerprint for each file. In this step, you'll build a script that loops through a directory and prints each file's SHA-256 hash to the terminal.

In this step, get ready to:
  • Create fim.sh with a hashing loop.
  • Run the script and see SHA-256 hashes for your test files.
Create fim.sh with the Bash shebang, strict mode, and a loop that hashes files

Your script needs three things: a shebang line telling macOS to run it with Bash, strict mode to catch mistakes early, and a loop that feeds each file to shasum.

  • Create a new file by running this command:
vim fim.sh
  • Press i to enter Vim's insert mode.
  • Type the following code into the editor:
#!/bin/bash

# Exit on errors, undefined variables, and pipe failures
set -euo pipefail

# Store the directory path from the first argument
target_dir="$1"

# Loop through every item in the target directory
for file in "$target_dir"/*; do
    # Only process regular files, skip directories
    if [[ -f "$file" ]]; then
        shasum -a 256 "$file"
    fi
done

What does this code do?

  • #!/bin/bash is the shebang. It tells macOS to run this script with Bash, even though the default shell is zsh.
  • set -euo pipefail enables strict mode. The script exits immediately on any error, undefined variable, or failed command in a pipe.
  • target_dir="$1" captures the first command-line argument you pass when running the script.
  • The for loop walks through every item in the directory. The [[ -f "$file" ]] test skips anything that isn't a regular file, like subdirectories.
  • shasum -a 256 prints each file's SHA-256 hash, a 64-character hex string that changes if even one byte in the file is different.
  • Press Esc to exit insert mode.
  • Type :wq and press Enter to save the file and quit Vim.
  • Confirm fim.sh was created by running:
ls

You should see fim.sh alongside the watched_files directory.

Don't see fim.sh?

  • Make sure you typed :wq (colon, w, q) and pressed Enter. If you're still inside Vim, press Esc first, then type :wq.
  • If you accidentally closed Vim without saving, reopen the file with vim fim.sh and re-enter the code.
  • Check that you're in the fim-project directory by running pwd. You should see ~/fim-project.

Help me figure out why my fim.sh file wasn't created after saving in Vim.

✔️ Awesome, I've got everything!

Great. Make sure you've saved fim.sh before moving on.

ⓧ I'd like to double check the full code

Here's the complete fim.sh file so far. Compare it against yours line by line.

#!/bin/bash

set -euo pipefail

target_dir="$1"

for file in "$target_dir"/*; do
    if [[ -f "$file" ]]; then
        shasum -a 256 "$file"
    fi
done
Make the script executable and run it

Bash scripts need execute permission before you can run them directly with ./. Without it, the system blocks the script from running.

  • Make fim.sh executable by running this command:
chmod +x fim.sh

Before you run the script, what do you think it will print for the three files in watched_files?

  • Run your script against the test directory by running this command:
./fim.sh ./watched_files

You should see three lines of output, one for each file. Each line contains a 64-character hex string (the hash) followed by the filename.

What is a SHA-256 hash?

SHA-256 takes any input, no matter how large, and produces a fixed 64-character fingerprint. Change even a single character in the file and the entire hash changes.

This is what makes it perfect for integrity monitoring. You can compare two hashes to instantly tell whether a file has been tampered with.

  • Check your script for common mistakes by running:
shellcheck fim.sh

If ShellCheck finds no issues, it prints nothing and returns you to the prompt. That silence means a clean bill of health.

Seeing Permission denied or a Bash error?

  • Permission denied means you skipped the chmod +x fim.sh step. Run it now and try again.
  • A syntax error message points to a typo in your script. Open the file with vim fim.sh and compare your code against the double-check tab above.
  • If you see no output at all (no hashes, no errors), make sure you passed the directory path. Run ./fim.sh ./watched_files with the ./watched_files argument included.

Help me debug why my fim.sh script isn't printing hashes.

Your script can fingerprint a directory on demand. The problem is, those hashes vanish the moment the terminal scrolls. Next up, you'll save those fingerprints to a file so you can compare them later and catch changes.

Save a Baseline Snapshot

In the previous step, you got your script hashing every file in a directory and printing the results to the terminal. Those hashes are the fingerprints your integrity monitor needs.

Printing hashes is useful for a one-time check. To detect changes over time, you need to save those hashes as a known-good "baseline" file that you can compare against later. In this step, you'll refactor your script to save hashes to a file and discover a critical limitation in the process.

In this step, get ready to:
  • Refactor the hashing loop into a reusable generate_hashes function.
  • Add an init_baseline function that saves hashes to a .fim_baseline file.
  • Test the baseline and discover a limitation in the current design.
Refactor the hashing logic into functions

Right now, all your hashing logic sits in a flat script. Wrapping it in a function makes it reusable and keeps variables from leaking into the global scope.

  • Open fim.sh in Vim by running:
vim fim.sh
  • Clear the entire file by typing gg (jump to top) followed by dG (delete to end) in normal mode.
  • Press i to enter insert mode, then add the following code:
#!/bin/bash

set -euo pipefail

# Filename for storing baseline hashes
BASELINE_FILE=".fim_baseline"

# Hash every file in the given directory
generate_hashes() {
    local target_dir="$1"

    for file in "$target_dir"/*; do
        # Skip non-files and the baseline file itself
        if [[ -f "$file" ]] && [[ "$(basename "$file")" != "$BASELINE_FILE" ]]; then
            shasum -a 256 "$file"
        fi
    done
}

generate_hashes "$1"

What does this code do?

  • BASELINE_FILE=".fim_baseline" defines the filename where hashes will be stored. Keeping it in a variable means you only change it in one place.
  • generate_hashes wraps the hashing loop in a reusable function. The local keyword scopes target_dir to the function so it does not leak into the global scope.
  • The basename check ensures the baseline file itself is never included in the hash list. Without this, re-running the script would hash its own output file.
  • Press Esc, then type :wq and press Enter to save and exit Vim.
  • Run the refactored script to confirm it still works by running:
./fim.sh ./watched_files

You should see three lines of output, each with a SHA-256 hash and a filename. The output looks the same as Step 2.

Not seeing three hashes?

  • Make sure you saved in Vim with :wq. If you see E37: No write since last change, your changes were not saved.
  • Check that the shebang line is exactly #!/bin/bash on the very first line with no spaces before it.
  • Confirm the script is still executable by running chmod +x fim.sh and trying again.
  • Why is my refactored fim.sh not printing hashes?
Add an init_baseline function

Your generate_hashes function prints hashes to the terminal. Now you need a second function that captures that output and saves it to a file as your known-good baseline.

  • Open fim.sh in Vim by running:
vim fim.sh
  • Position your cursor on the blank line after the closing } of the generate_hashes function.
  • Press o to open a new line below and enter insert mode.
  • Add the init_baseline function by typing the following code:
init_baseline() {
    local target_dir="$1"
    local baseline_path="$target_dir/$BASELINE_FILE"

    # Write all hashes into the baseline file
    generate_hashes "$target_dir" > "$baseline_path"

    # Count how many files were recorded
    local file_count
    file_count=$(wc -l < "$baseline_path" | tr -d ' ')

    echo "Baseline created: $baseline_path"
    echo "Files recorded: $file_count"
}

What does this code do?

  • init_baseline calls generate_hashes and redirects its output into the baseline file using >. Instead of printing to the terminal, the hashes are written to disk.
  • wc -l < "$baseline_path" counts the number of lines in the baseline file (one line per hashed file).
  • tr -d ' ' strips the leading whitespace that macOS wc adds to its output. Without it, the file count would print with extra spaces.

Now update the bottom of the script so it calls init_baseline instead of generate_hashes directly.

  • Press Esc to exit insert mode.
  • Press G to jump to the last line of the file.
  • Find this line:
generate_hashes "$1"
  • Press cc to clear the line and enter insert mode. Replace it with:
init_baseline "$1"
  • Press Esc, then type :wq and press Enter to save and exit Vim.

✔️ Awesome, I've got everything!

Great. Double check you saved fim.sh before moving on.

ⓧ I'd like to double check the full code

Here is the complete fim.sh at this point. Compare it against yours line by line.

#!/bin/bash

set -euo pipefail

BASELINE_FILE=".fim_baseline"

generate_hashes() {
    local target_dir="$1"

    for file in "$target_dir"/*; do
        if [[ -f "$file" ]] && [[ "$(basename "$file")" != "$BASELINE_FILE" ]]; then
            shasum -a 256 "$file"
        fi
    done
}

init_baseline() {
    local target_dir="$1"
    local baseline_path="$target_dir/$BASELINE_FILE"

    generate_hashes "$target_dir" > "$baseline_path"

    local file_count
    file_count=$(wc -l < "$baseline_path" | tr -d ' ')

    echo "Baseline created: $baseline_path"
    echo "Files recorded: $file_count"
}

init_baseline "$1"
Test the baseline, then hit the limitation

Time to see your baseline in action. You will create a snapshot, tamper with a file, and find out whether the script catches the change.

  • Create a baseline of your watched files by running:
./fim.sh ./watched_files

You should see two lines of output: Baseline created: ./watched_files/.fim_baseline and Files recorded: 3.

  • Verify the baseline file was created correctly by running:
cat ./watched_files/.fim_baseline

You should see three lines, each with a 64-character hex hash followed by a file path.

Seeing 0 files recorded?

  • Confirm your test files still exist by running ls ./watched_files/. You should see passwords.txt, config.ini, and notes.txt.
  • Check that the generate_hashes function uses "$target_dir"/* with the quotes and asterisk exactly as shown.
  • Why is my baseline file empty when I run fim.sh?

Your baseline is saved. Now simulate an attacker modifying a file.

  • Tamper with passwords.txt by running:
echo "HACKED" >> ./watched_files/passwords.txt

Before you run the script again, what do you think will happen? Will the script detect that passwords.txt was tampered with?

  • Run the script again by running:
./fim.sh ./watched_files

You see Baseline created and Files recorded: 3 again. No alert. No comparison. No indication that passwords.txt was tampered with.

Why is this a problem?

The script silently overwrites the baseline every time it runs. It can take a snapshot, but it has no memory of what came before. A real integrity monitor needs to compare new hashes against the saved baseline and flag differences.

This is the exact gap you will close in the next step by adding a separate --check mode that compares without overwriting.

  • Run ShellCheck one more time to verify your refactored script has no issues:
shellcheck fim.sh

If ShellCheck prints nothing, your script is clean. No output means no warnings.

Your script can fingerprint files and save a baseline. The missing piece is comparison logic that catches changes without overwriting the evidence. Next up, you will add a check mode that detects tampered, added, and deleted files with color-coded alerts.

Detect Tampered, Added, and Deleted Files

In Step 3, you saw the gap firsthand. You saved a baseline, tampered with a file, and ran the script again. It silently overwrote the baseline with no comparison, no alert, no sign that anything changed.

This step fills that gap. You will add comparison logic that reads the saved baseline, re-hashes every file, and flags exactly what changed. Color-coded terminal output will make each alert type instantly recognizable.

In this step, get ready to:
  • Add ANSI color codes for colored terminal output.
  • Build a check_integrity function that detects modified, added, and deleted files.
  • Route CLI arguments with a case statement for --init and --check modes.
Add ANSI color codes and a check_integrity function

Terminal text is plain white by default. To make alerts stand out, you can embed special escape sequences called ANSI color codes that tell the terminal to render text in a specific color.

  • Open fim.sh in Vim by running:
vim fim.sh
  • Find the blank line between set -euo pipefail and BASELINE_FILE=".fim_baseline". Add these color variable definitions between them:
# ANSI color codes for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

What are ANSI escape codes?

Each variable stores an escape sequence like \033[0;31m. When the terminal encounters this sequence inside echo -e, it switches the text color instead of printing the characters literally.

NC stands for "no color." It resets the terminal back to its default color after each colored message.

  • Now update init_baseline to use these colors. Scroll to the init_baseline function and find this line:
    generate_hashes "$target_dir" > "$baseline_path"
  • Add this new line directly above it:
    echo -e "${BLUE}Generating baseline for: $target_dir${NC}"
  • Then find these two lines near the bottom of init_baseline:
    echo "Baseline created: $baseline_path"
    echo "Files recorded: $file_count"
  • Replace them with these color-coded versions:
    echo -e "${GREEN}Baseline created: $baseline_path${NC}"
    echo -e "${GREEN}Files recorded: $file_count${NC}"
  • Save and exit Vim with :wq.
  • Test the colored output by running:
./fim.sh ./watched_files

You should see "Generating baseline for" in blue and "Baseline created" and "Files recorded" in green.

Still seeing plain white text?

  • Make sure you changed echo to echo -e on all three lines. The -e flag enables escape sequence interpretation.
  • Check that the color variables are placed above BASELINE_FILE, not inside a function. They need to be global so every function can reference them.
  • help me debug my ANSI color codes in fim.sh

Colors are working. Now for the real logic. You need a function that reads the saved baseline, re-hashes every file, and reports what changed.

  • Open fim.sh in Vim again by running:
vim fim.sh
  • Scroll to the closing } of the init_baseline function. Add a blank line below it, then copy this code to start building check_integrity:
check_integrity() {
    local target_dir="$1"
    local baseline_path="$target_dir/$BASELINE_FILE"

    echo -e "${BLUE}Checking integrity of: $target_dir${NC}"
    echo ""
    # Track counts for each change type
    local modified=0
    local added=0
    local deleted=0

    # Compare each baseline entry against current state
    while IFS= read -r line; do
        local stored_hash
        stored_hash=$(echo "$line" | awk '{print $1}')
        local stored_file
        stored_file=$(echo "$line" | awk '{print $2}')

        if [[ ! -f "$stored_file" ]]; then
            echo -e "${RED}[DELETED]  $stored_file${NC}"
            deleted=$((deleted + 1))
        else
            local current_hash
            current_hash=$(shasum -a 256 "$stored_file" | awk '{print $1}')
            if [[ "$stored_hash" != "$current_hash" ]]; then
                echo -e "${YELLOW}[MODIFIED] $stored_file${NC}"
                modified=$((modified + 1))
            fi
        fi
    done < "$baseline_path"

What does this code do?

  • while IFS= read -r line reads the baseline file one line at a time. Each line contains a hash and a filename.
  • awk '{print $1}' extracts the first field (the stored hash) and awk '{print $2}' extracts the second field (the file path).
  • If the file no longer exists on disk, it prints [DELETED] in red.
  • If the file exists but its current SHA-256 hash differs from the stored hash, it prints [MODIFIED] in yellow.
  • done < "$baseline_path" feeds the baseline file into the while loop via input redirection.

Do not save or close Vim yet. The function still needs logic to detect newly added files, and the script needs a way to route CLI arguments.

Add a case statement for CLI argument routing

The while loop catches deleted and modified files, but it only compares files that were in the baseline. A file that appeared after the baseline was created would be missed entirely.

  • Still in Vim, scroll to the done < "$baseline_path" line you just typed. Add a blank line below it, then copy this code to detect newly added files and close the function:
    # Check for new files not in the baseline
    for file in "$target_dir"/*; do
        if [[ -f "$file" ]] && [[ "$(basename "$file")" != "$BASELINE_FILE" ]]; then
            if ! grep -F -q "$file" "$baseline_path"; then
                echo -e "${YELLOW}[ADDED]    $file${NC}"
                added=$((added + 1))
            fi
        fi
    done
}

How does this detect new files?

This for loop walks through every file currently in the directory. For each file, grep -F -q checks whether the filename appears anywhere in the baseline. The -F flag means fixed-string matching (no regex), and -q suppresses output so it just sets the exit code.

If a file is not found in the baseline, it must have been added after the snapshot was taken.

The function is complete. Now the script needs a way to reach it from the command line. Right now, the bottom of the file directly calls init_baseline "$1" every time.

  • Scroll to the very bottom of the file and find this line:
init_baseline "$1"
  • Delete that line and replace it with this case statement:
# Route CLI arguments to the right function
case "$1" in
    --init)
        init_baseline "$2"
        ;;
    --check)
        check_integrity "$2"
        ;;
esac

What does the case statement do?

A case statement in Bash matches the value of a variable against patterns and runs the corresponding block. It works like a switch statement in other languages.

Here, $1 is the first argument the user passes (like --init or --check). The script routes to init_baseline or check_integrity, passing $2 (the directory path) as the argument.

  • Save and exit Vim with :wq.
Test the full detection flow

Time to prove the tool catches every type of change. You will create a fresh baseline, tamper with the directory in three different ways, and run the check.

  • Create a fresh baseline using the new --init flag by running:
./fim.sh --init ./watched_files

You should see the blue "Generating baseline for" message followed by green confirmation with a file count of 3.

  • Now tamper with the directory in three ways by running these commands:
echo "COMPROMISED" >> ./watched_files/passwords.txt
echo "malicious code" > ./watched_files/malware.sh
rm ./watched_files/notes.txt

What did you just do?

  • Modified passwords.txt by appending a new line (its SHA-256 hash will change).
  • Created malware.sh, a brand-new file that was not in the baseline.
  • Deleted notes.txt, removing a file that the baseline expects to find.

Before you run the check, think about what you expect to see. Which file should be flagged as modified? Which as added? Which as deleted?

  • Run the integrity check by running:
./fim.sh --check ./watched_files

You should see three colored alerts. [MODIFIED] in yellow for passwords.txt. [ADDED] in yellow for malware.sh. [DELETED] in red for notes.txt.

Not seeing all three alerts?

  • If you see no output at all, make sure you ran --init before --check. The check needs a baseline file to compare against.
  • If [ADDED] is missing, check that the for loop at the bottom of check_integrity is outside the while loop. Both loops should be at the same indentation level inside the function.
  • If you see a syntax error, run shellcheck fim.sh to pinpoint the exact line and issue.
  • help me debug my check_integrity function in fim.sh

✔️ Awesome, I've got everything!

Great. Double check you have saved fim.sh and that all three alert types appear when you run --check.

ⓧ I'd like to double check the full code

Your fim.sh should now have these sections, in this order. Scroll through your file and confirm each one is present:

  • Shebang (#!/bin/bash) and strict mode (set -euo pipefail).
  • Five ANSI color variables: RED, GREEN, YELLOW, BLUE, NC.
  • BASELINE_FILE=".fim_baseline" variable.
  • generate_hashes function with a for loop and shasum -a 256.
  • init_baseline function with blue "Generating baseline" and green "Baseline created" / "Files recorded" messages.
  • check_integrity function containing a while IFS= read -r loop (for DELETED and MODIFIED) followed by a for loop (for ADDED).
  • A case statement at the bottom routing --init and --check.

Your File Integrity Monitor can now detect all three types of changes: modifications, additions, and deletions. Next up, you will add input validation, a help menu, and a summary report to make this a polished CLI tool.

Add Help, Validation, and a Summary Report

Your File Integrity Monitor can now detect tampered, added, and deleted files with color-coded alerts. That detection logic is the heart of the tool.

Right now, though, the script crashes if you give it bad input. Try running it without arguments and it exits with an ugly error. A security tool that breaks on unexpected input is hard to trust. In this step, you will add a help menu, defensive input validation, and a summary report that gives a quick threat count at a glance.

In this step, get ready to:
  • Build a help menu that prints usage instructions and examples.
  • Add input validation that catches bad arguments before the script crashes.
  • Print a summary report with counts of modified, added, and deleted files.
Add a show_help function and error handling

Every polished CLI tool prints a usage guide when you ask for help or type something wrong. Your FIM needs one too.

  • Open fim.sh in Vim by running:
vim fim.sh
  • Find the BASELINE_FILE=".fim_baseline" line. Add the following show_help function below it, with a blank line separating them:
show_help() {
    echo "File Integrity Monitor (FIM)"
    echo ""
    echo "Usage: ./fim.sh [OPTION] [DIRECTORY]"
    echo ""
    echo "Options:"
    echo "  --init DIR     Generate a baseline for DIR"
    echo "  --check DIR    Check DIR against its baseline"
    echo "  --help         Show this help message"
    echo ""
    echo "Examples:"
    echo "  ./fim.sh --init ./watched_files"
    echo "  ./fim.sh --check ./watched_files"
}

What does this function do?

show_help prints a formatted usage guide to the terminal. It lists every available option with a description, plus working examples the user can copy.

Now the script needs to actually call this function. You will add a guard that catches missing arguments, plus new cases in the case statement for --help and unknown options.

  • Scroll to the bottom of the file. Find the existing case block that starts with case "$1" in.
  • Delete everything from case "$1" in through esac and replace it with the following code:
# Exit with help if no arguments provided
if [[ $# -lt 1 ]]; then
    show_help
    exit 1
fi

case "$1" in
    --init)
        init_baseline "$2"
        ;;
    --check)
        check_integrity "$2"
        ;;
    --help)
        show_help
        ;;
    *)
        echo -e "${RED}Unknown option: $1${NC}"
        show_help
        exit 1
        ;;
esac

What changed here?

The if [[ $# -lt 1 ]] guard checks whether the user provided any arguments at all. If not, it shows help and exits before the case statement tries to read $1.

The --help case calls show_help directly. The wildcard * case catches anything that is not a recognized option, prints an error in red, and shows the help menu so the user knows what to type instead.

  • Save and exit Vim with :wq.
  • Test the help menu by running:
./fim.sh --help

You should see your formatted usage guide with the options and examples.

  • Test the no-argument guard by running:
./fim.sh

You should see the same help output. Previously this would have crashed with an error about an unbound variable.

  • Test the wildcard case by running:
./fim.sh --banana

You should see a red "Unknown option" message followed by the help guide.

Help menu not showing?

  • Check that show_help is defined ABOVE the if [[ $# -lt 1 ]] guard. Bash reads top to bottom, so the function must exist before it is called.
  • Make sure the --help) and *) cases end with ;; (double semicolons). Missing terminators cause syntax errors.
  • Still stuck? Why is my show_help function not printing output when I run ./fim.sh --help?
Add input validation and the summary report

The help menu handles missing and unknown arguments. But the script still crashes if you point --init at a directory that does not exist, or run --check before creating a baseline. You need validation checks inside each function to catch those mistakes gracefully.

  • Open fim.sh in Vim by running:
vim fim.sh
  • Find the init_baseline function. Locate the line that says echo -e "${BLUE}Generating baseline for: $target_dir${NC}".
  • Add this directory validation check directly above that echo line:
    # Validate the target directory exists
    if [[ ! -d "$target_dir" ]]; then
        echo -e "${RED}Error: '$target_dir' is not a valid directory.${NC}"
        exit 1
    fi
  • Now find the check_integrity function. Locate the line that says echo -e "${BLUE}Checking integrity of: $target_dir${NC}".
  • Add this baseline file validation check directly above that echo line:
    # Validate the baseline file exists
    if [[ ! -f "$baseline_path" ]]; then
        echo -e "${RED}Error: No baseline found at $baseline_path${NC}"
        echo "Run './fim.sh --init $target_dir' first."
        exit 1
    fi

What do these checks do?

The [[ ! -d "$target_dir" ]] test checks if the given path is NOT a directory. If someone types a path that does not exist, the script prints a clear red error and exits instead of crashing mid-loop.

The [[ ! -f "$baseline_path" ]] test checks if the baseline file is missing. If someone tries --check before running --init, the error message tells them exactly what to do.

  • Save and exit Vim with :wq.
  • Test the directory validation by running:
./fim.sh --init ./nonexistent

You should see a red error message saying ./nonexistent is not a valid directory. The script exits cleanly instead of crashing.

Now you need two more pieces of validation: checking that --init and --check both receive a directory argument. You also need the summary report that totals up all changes at the end of a check.

  • Open fim.sh in Vim again by running:
vim fim.sh
  • Scroll to the case statement at the bottom. Find the --init) case, which currently looks like this:
    --init)
        init_baseline "$2"
        ;;
  • Replace the --init) case with this version that validates the second argument:
    --init)
        if [[ $# -lt 2 ]]; then
            echo -e "${RED}Error: --init requires a directory argument.${NC}"
            exit 1
        fi
        init_baseline "$2"
        ;;
  • Apply the same pattern to the --check) case. Replace it with this:
    --check)
        if [[ $# -lt 2 ]]; then
            echo -e "${RED}Error: --check requires a directory argument.${NC}"
            exit 1
        fi
        check_integrity "$2"
        ;;

The last missing piece is the summary report. After the check finishes printing individual alerts, it should total everything up.

  • Scroll up to the check_integrity function. Find the closing done of the for file in "$target_dir"/* loop (the last done inside the function).
  • Add the following summary report code below that done, still inside the check_integrity function (before the closing }):
    echo ""
    echo "--- Integrity Report ---"
    local total=$((modified + added + deleted))
    if [[ "$total" -eq 0 ]]; then
        echo -e "${GREEN}All files intact. No changes detected.${NC}"
    else
        echo -e "Modified: $modified"
        echo -e "Added:    $added"
        echo -e "Deleted:  $deleted"
        echo -e "${RED}Total alerts: $total${NC}"
    fi

What does this report do?

The report adds up all three change counters (modified, added, deleted) into a single total. If the total is zero, it prints a green all-clear. If changes were detected, it breaks down each category and prints the total alert count in red.

  • Save and exit Vim with :wq.

Time to test the full polished tool. First, restore your test files to a clean state by running these commands:

  • Reset your test directory by running:
rm -f ./watched_files/malware.sh
echo "admin_password=letmein" > ./watched_files/passwords.txt
echo "host=localhost" > ./watched_files/config.ini
echo "check logs weekly" > ./watched_files/notes.txt
  • Create a fresh baseline by running:
./fim.sh --init ./watched_files

You should see the blue "Generating baseline" message, then green confirmation with 3 files recorded.

  • Simulate a set of changes by running these commands:
echo "HACKED" >> ./watched_files/passwords.txt
touch ./watched_files/malware.sh
rm ./watched_files/notes.txt

Before you run the check, predict what you expect to see. How many alerts should appear? What color will each one be?

  • Run the integrity check to see the full output:
./fim.sh --check ./watched_files

You should see three alerts: yellow MODIFIED for passwords.txt, yellow ADDED for malware.sh, and red DELETED for notes.txt. Below the alerts, the new Integrity Report shows the breakdown with a red total of 3.

  • Run shellcheck one final time to verify a clean bill of health:
shellcheck fim.sh

If shellcheck prints nothing, your script is clean. No warnings, no errors.

  • Restore your test files to a clean state for future use by running:
rm -f ./watched_files/malware.sh
echo "admin_password=letmein" > ./watched_files/passwords.txt
echo "check logs weekly" > ./watched_files/notes.txt

Summary report not printing?

  • Make sure the summary code is inside the check_integrity function, after the second done and before the closing }. If it is outside the function, the counter variables will not be in scope.
  • Check that local total=$((modified + added + deleted)) uses $(( )) with double parentheses. Single parentheses will cause a syntax error.
  • If shellcheck reports warnings, read the line numbers it points to. They usually indicate a typo or a missing quote.
  • Still stuck? Help me debug why my Integrity Report summary is not appearing at the end of the check output.

✔️ Awesome, I've got everything!

Great. Double check you have saved fim.sh and that shellcheck fim.sh reports no warnings.

ⓧ I'd like to double check the full code

The full file is long, so scroll through fim.sh and confirm these sections appear in order:

  • Shebang (#!/bin/bash) and strict mode (set -euo pipefail).
  • Five color variables (RED, GREEN, YELLOW, BLUE, NC) and BASELINE_FILE.
  • show_help function with usage, options, and examples.
  • generate_hashes function with the file loop and shasum -a 256.
  • init_baseline function with the [[ ! -d "$target_dir" ]] directory check near the top.
  • check_integrity function with the [[ ! -f "$baseline_path" ]] baseline check near the top and the Integrity Report at the bottom.
  • The if [[ $# -lt 1 ]] guard followed by the case statement with --init, --check, --help, and * cases (each with argument validation).

If anything looks off, open vim fim.sh and compare each section against the code blocks above.

Your File Integrity Monitor is complete. It fingerprints files, detects tampering, validates input, and reports results with color-coded alerts and a summary. Next up, a Secret Mission to make it even more powerful.

Secret mission

Add Recursive Scanning and Audit Logging

Your File Integrity Monitor watches a flat directory. Real directories have subdirectories, and real security tools keep audit trails. In this mission, you will upgrade the FIM to scan subdirectories recursively using find and write timestamped log entries for every event it detects.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your Mac, so there are no ongoing costs. Decide whether to keep your files for future use or delete them entirely.

Resources you used:

  • ~/fim-project/ directory containing fim.sh, watched_files/ (with test files, baseline, log, and subdirectory).
  • ShellCheck installed via Homebrew.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your File Integrity Monitor or add more features.

  • Your fim.sh script and watched_files/ directory stay on your Mac at no cost.
  • ShellCheck remains installed and available for linting any future Bash scripts you write.

Pause - I'll come back to this later

There are no running processes to stop. Your script and files are saved on disk and ready whenever you return.

  • Close any open Terminal windows. Everything is already saved.
  • When you come back, open Terminal and run:
cd ~/fim-project

You will be right back in your project directory, ready to pick up where you left off.

Delete - I don't want to use this again

Remove all project files and optionally uninstall ShellCheck.

  • Delete the entire project directory by running:
rm -rf ~/fim-project
  • Confirm the directory is gone by running:
ls ~/fim-project

You should see a "No such file or directory" message.

  • If you want to remove ShellCheck as well, run:
brew uninstall shellcheck

Nice Work!

Nice Work!

You just built a fully functional File Integrity Monitor from scratch using Bash. Point it at any directory, fingerprint every file with SHA-256 hashes, and catch tampering the moment it happens.

You've learned how to:

  • Built a File Integrity Monitor that detects modified, added, and deleted files using cryptographic hashing with shasum -a 256, the same algorithm used in production security tools.
  • Written a polished CLI tool with command-line argument parsing, input validation, color-coded output using ANSI color codes, and a summary report.
  • Applied core Bash scripting patterns including strict mode (set -euo pipefail), functions with local variables, while read loops, [[ ]] conditionals, and case statements, all linted with ShellCheck and edited entirely in Vim.
  • Secret Mission: Added recursive subdirectory scanning with find and a timestamped audit log.

Ready to quiz yourself?