English · Русский
A small, auditable monitor that detects unauthorized file changes in your web applications
and alerts you via Telegram, MAX and email. It runs git status against each tracked
working tree on a schedule and reports only genuinely new deviations — the same signal that
catches dropped web shells (untracked ?? evil.php) and injected files (modified
vendor/composer/autoload_real.php).
Pure Python 3 + coreutils, no external packages. Designed to stay quiet on noisy repos and to resist the obvious evasion tricks.
A production web server (a Joomla site, some legacy PHP, a couple of Laravel apps) was breached
through a vulnerable Joomla component. The attacker dropped a zoo of web-shell families and, worse,
a re-infector welded into Composer's vendor/composer/autoload_real.php that ran on every
request and kept re-dropping file-manager shells into surname-named dirs, each with its own
PHP-re-enabling .htaccess — delete a shell, refresh the page, it's back.
What actually surfaced the breach was nothing fancy: git status. The code lived in git, so the
attack showed up as a wall of untracked entries (?? — the dropped shells) and modified tracked
files ( M — the injected index.php and autoload_real.php). The git baseline already knew what
belonged; everything else was the attack, laid out plainly. That one diagnostic, run once,
found the compromise — so why not run it continuously and have it message you the moment a new
deviation appears? That is gitmon.
| AIDE / Tripwire | inotify FIM | gitmon | |
|---|---|---|---|
| Knows legit-vs-unexpected | builds its own DB of every file | watches paths, no notion of "expected" | reuses the git baseline — legit code is known for free |
| Noise on big upload dirs | high (every upload is a "change") | high | delta-quiet — baseline absorbs known untracked files |
| Injected tracked code | yes (checksum) | yes (write event) | yes ( M) + names the high-value files |
| Commit-to-clean evasion | n/a | n/a | caught via HEAD tracking |
Hide-by-.gitignore evasion |
n/a | n/a | caught via .gitignore hashing |
| Setup | define policy, init DB | configure watches | point at repos that already exist |
| Alerting | log / email (varies) | DIY | Telegram + MAX + email built in |
| Dependencies | package + DB | inotify tooling | Python 3 stdlib, no DB, no agent |
| Dead-man switch | external | external | built-in watchdog |
FIM tools are powerful but have to learn your filesystem from scratch and don't understand "this 400k-file uploads folder is normal." gitmon starts from the answer your repo already encodes.
| CMS plugins | gitmon | |
|---|---|---|
| Where it runs | inside the web app (itself attackable) | outside the webroot, server-side |
| Scope | one CMS install | all repos / all sites on the box at once |
| CMS-agnostic | no (per-platform) | yes — Joomla, WordPress, Laravel, plain PHP |
| Survives app compromise | not necessarily | yes — separate process, config outside webroot |
A plugin lives inside the thing being attacked. gitmon watches from the outside, across every site.
After a breach, attackers drop shells and inject code into the document root. In a git-managed
site these show up immediately as untracked (??) or modified ( M) entries — exactly
what git status surfaces. gitmon turns that one-off forensic trick into continuous monitoring.
- Discovers + dedups the configured git working trees (by realpath).
- Per repo runs
git status --porcelain=v1 --untracked-files=all -z(NUL-safe parsing, robust to spaces/newlines/UTF-8 in filenames), plus the current HEAD and the hashes of tracked.gitignorefiles. - Delta alerting: the first run silently records a baseline; afterwards it alerts only on records that are new vs the baseline. This is essential — a real site can have hundreds of thousands of untracked uploads; you must not alert on all of them, only on what newly appears.
- Severity
HIGH— a new file with an executable-PHP extension (.php .phtml .php5 .pht .suspected .htaccess), a tracked code file modified/added/deleted, force-HIGH IOCs (vendor/composer/autoload_real.php, anyindex.php,.htaccess,.gitignorechange).INFO— everything else (normal media uploads.jpg/.png/.gif, docs, etc.). Withnotify_level = HIGH(default) INFO is logged and folded into the baseline but not paged.
- Content second opinion (optional): new untracked media/text files are passed to a content
scanner (scan-webshells). A shell disguised as an
image (
GIF89a<?php …) is promoted to HIGH by content, even though its extension looks benign. - Anti-evasion
- HEAD tracking — catches an attacker who commits the shell to make the working tree clean (a clean fast-forward on the same branch is INFO; non-fast-forward / suspicious files in the diff are HIGH).
.gitignorehashing — catches "hide the file by adding it to .gitignore".
- Notifications: one aggregated message per run, sent only when there are new findings, to
Telegram + MAX + email. Tokens never appear in process arguments (sent via
urllibin-process). - Watchdog (dead-man switch): a separate cron job alerts if gitmon stops running or if alert delivery has been failing — so silencing the monitor is itself noticed.
# as root, from a checkout of this repo:
./install.sh
# creates /root/git-watch (700), installs the scripts, the cron jobs and a logrotate rule,
# then silently seeds the baselines.Then edit the config and list your repos:
nano /root/git-watch/gitmon.conf # tokens, recipients, [repos]Cron runs gitmon every 10 minutes and the watchdog every 30.
See gitmon.conf. Key fields:
| Section | Field | Meaning |
|---|---|---|
[telegram] |
token / chat_id |
bot token (@BotFather) + target chat (empty = Telegram off) |
api_base |
default api.telegram.org; set to a reverse-proxy if Telegram is blocked |
|
[max] |
token / chat_id |
MAX bot token + admin user_id (empty = MAX off) |
[email] |
recipients / from |
sent via local sendmail |
[options] |
notify_level |
HIGH (quiet) or INFO (page everything) |
force_root_git |
0 = run git as repo owner (sudo), 1 = as root |
|
use_scanner / scanner_path |
enable the content second opinion | |
[prune] |
patterns |
substrings to ignore for untracked noise (never affects tracked code) |
[repos] |
label = /path |
one git working tree per line |
/root/git-watch/run.sh # normal run (seed if no baseline, else alert on delta, then fold)
/root/git-watch/run.sh --dry # show findings + what WOULD be sent; change nothing, send nothing
/root/git-watch/run.sh --seed # (re)seed baselines silently
cat /root/git-watch/gitmon.log # history- Script and config live outside the web root, root-owned; the config is
chmod 600so an attacker with web-server-level access cannot read the bot tokens. - Bot tokens are passed to the HTTP layer in-process (urllib), never as command arguments, so they don't appear in the process list.
- State (baselines) is root-owned; the cron and watchdog are root jobs.
- Monitors git working trees only. Files dropped outside any repo (e.g. a non-git uploads area) are not seen by git status — pair gitmon with a content scanner sweep for those.
- The signature/severity rules are tuned for PHP web stacks; adjust
CODE_EXT/[prune]/ the scanner for your environment.
MIT — see LICENSE.
