first commit

This commit is contained in:
2025-09-17 12:13:02 -04:00
commit e3a5efe56f
25 changed files with 2276 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
config/Settings.toml
Cargo.lock
.idea/
target/

31
Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "watchman"
version = "0.2.2"
edition = "2021"
description = "Detect and Act on unauthorized access of any kind from any source."
authors = ["jkoontsiii@gmail.com"]
license = "MIT"
repository = "https://github.com/helloimalemur/fortiwatch"
keywords = ["file-integrity", "filesystem-integirty", "change-detection", "watchman"]
readme = "README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix = "0.13.5"
actix-web = "4.10.2"
anyhow = "1.0.97"
blake3 = "1.6.1"
chrono = "0.4.40"
clap = { version = "4.5.32", features = ["derive"] }
config = "0.15.11"
discord-webhook-lib = "0.2.1"
filesystem-hashing = "0.3.4"
httping = "0.1.9"
mime = "0.3.17"
netstat = "0.7.0"
prometheus = { version = "0.13.4" }
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.44.1", features = ["rt", "rt-multi-thread", "macros"] }
walkdir = "2.5.0"
whoami = "1.5.2"
lazy_static = "1.5.0"

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# Watchman
#### (work-in-progress)
## Detect and Act on unauthorized access of any kind from any source
### Detect and Act on;
- an increase of USB devices
- network issues or network failure
- filesystem changes
- ssh "burn file"
#### Observed memory usage <100MB to ~1GB
### Install
```shell
## install binary
cargo install watchman
## configure service with discord webhook
watchman install-service webhook=https://discordapp.com/api/webhooks/121946119953658680...
```
# Setup
### create config/Settings.toml
```shell
## General settings
tick_delay_seconds = "5"
fs_tick_delay_seconds = "60"
### File System Integrity
fs_mon_enabled = "true"
fs_mon_dir = ["/etc", "/bin", "$PATH"]
fs_mon_hash_type = "blake3"
### USB Monitor
usb_mon_enabled = "true"
reboot_on_increase_of_usb_devices = "false"
notify_on_increase_of_usb_devices = "true"
unmount_crypt_on_increase_of_usb_devices = "true"
### Burn File Monitor
burn_file_mon_enabled = "false"
unmount_crypt_on_file_burn = "true"
ssh_check_burn_host = "hostname"
ssh_check_burn_user = "root"
ssh_check_burn_key = "/home/user/.ssh/id_rsa"
ssh_check_burn_path = "/root/.config/burn"
ssh_check_burn_check_interval = "30"
burn_path_1 = "/root/test/"
### Network Monitor
net_mon_enabled = "false"
######## Notification settings
discord_webhook_url = "https://discord.com/api/webhooks/"
discord_webhook_avatar_name = "Lazarus"
```
## Development and Collaboration
#### Feel free to open a pull request, please run the following prior to your submission please!
echo "Run clippy"; cargo clippy -- -D clippy::all
echo "Format source code"; cargo fmt -- --check

3
install.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
sudo cargo install --path ./ --root /usr/local/
sudo /usr/local/bin/watchman -s install

113
plan/features.md Normal file
View File

@@ -0,0 +1,113 @@
Heres a breakdown of how you can implement the free and paid features in a structured way, keeping the architecture scalable.
Feature Implementation Breakdown (Free vs. Paid)
1. Core Monitoring (Free)
✅ Implemented in Open-Source Version
• USB device monitoring
• Filesystem integrity checks
• Network failure detection
• “Burn file” SSH monitoring
• Discord webhook notifications
Implementation:
• Filesystem Monitoring: Use inotify (Linux) or fswatch (cross-platform).
• USB Monitoring: Parse /sys/bus/usb/devices/ or lsusb output.
• Network Monitoring: Use ping or netlink API to detect failures.
• SSH Burn File: Periodically check for the existence of a file (stat(), fs::metadata() in Rust).
2. USB & Peripheral Lockdown (Paid)
🚀 Paid Feature
• Automatically disable unauthorized USB devices.
• Whitelist and blacklist management.
Implementation:
• Detect devices using udevadm monitor --property.
• Maintain an allowlist of trusted USB device IDs.
• On detection of unauthorized devices:
• Linux: Run echo "1" > /sys/bus/usb/devices/usbX/remove
• Windows: Use PowerShell scripts to disable USB ports.
3. Advanced Notifications & Webhooks (Paid)
🚀 Paid Feature
• Support for Slack, Telegram, Microsoft Teams, Email, and SMS alerts.
Implementation:
• Add support for multiple API integrations (e.g., Slack Webhooks, Twilio for SMS).
• Use an event-driven system to trigger notifications based on user-defined rules.
4. Cloud Logging & Threat Intelligence (Paid)
🚀 Paid Feature
• Store logs remotely for auditing.
• Use AI to detect unusual patterns.
Implementation:
• Use Rust-based REST API (Actix/Web or Axum) to send logs to a cloud storage backend.
• Provide encrypted logs stored in a database (PostgreSQL, SQLite).
• Apply basic anomaly detection (e.g., tracking abnormal file deletions or SSH logins).
5. Tamper Resistance & Self-Healing (Paid)
🚀 Paid Feature
• Auto-recover from unauthorized file changes.
• Prevent attackers from disabling the system.
Implementation:
• Detect unauthorized changes using hash comparison (Blake3).
• Auto-restore critical files from a backup archive if tampered.
• Use a hidden watchdog process that restarts monitoring services if terminated.
6. Mobile Dashboard & Web UI (Paid)
🚀 Paid Feature
• Control system settings from a web app or mobile device.
Implementation:
• Build a Rust-based API backend with a database.
• Use React Native for a simple cross-platform mobile app.
• Connect API to fetch logs and allow actions (e.g., remote USB lockdown).
7. Advanced Response Actions (Paid)
🚀 Paid Feature
• Automatically lock the system or rotate SSH keys if a threat is detected.
Implementation:
• Run predefined system commands (e.g., shutdown -h now).
• Auto-rotate SSH keys using ssh-keygen and update ~/.ssh/authorized_keys.
Code Structure & Monetization Approach
1. Free Version
• Core monitoring features.
• Basic alerting via Discord.
• Local-only logs.
2. Paid Version
• Feature flagging system (cfg(feature = "paid") in Rust).
• API keys to unlock premium features (cloud logging, advanced alerts).
• Subscription-based licensing.
Next Steps
Would you like boilerplate Rust code for the paid features implementation? 🚀

15
plan/monetization.md Normal file
View File

@@ -0,0 +1,15 @@
# SentinelGuard - Free vs. Paid Features
| Feature | **Free Version** | **Paid Version** |
|---------|----------------|-----------------|
| **Basic Intrusion Detection** | ✅ Monitors USB, Filesystem, SSH, and Network | ✅ Advanced detection algorithms, anomaly tracking |
| **USB Monitoring** | ✅ Alerts on USB device changes | ✅ Auto-disable unauthorized USB devices, whitelist/blacklist management |
| **Filesystem Integrity Monitoring** | ✅ Detects changes in sensitive directories | ✅ Automated rollback, ransomware protection |
| **Network Monitoring** | ✅ Detects network failures | ✅ Logs suspicious traffic, integrates with SIEM tools |
| **"Burn File" SSH Monitoring** | ✅ Alerts when a specific file is removed | ✅ Auto-revoke SSH keys, force logout, disable user |
| **Notifications & Webhooks** | ✅ Discord webhook support | ✅ Slack, Telegram, Microsoft Teams, Email, and SMS alerts |
| **Remote Logging & Cloud Sync** | ❌ Local logs only | ✅ Secure cloud logging, real-time alerts |
| **Threat Intelligence & Machine Learning** | ❌ Simple rule-based detection | ✅ AI-driven anomaly detection, auto-response recommendations |
| **Tamper Resistance & Self-Healing** | ❌ Alerts on unauthorized changes | ✅ Auto-recovery from system tampering, self-healing configs |
| **Mobile Dashboard & Web UI** | ❌ CLI only | ✅ Web and mobile app for managing policies, alerts, and responses |
| **Advanced Response Actions** | ❌ Manual intervention needed | ✅ Automated lockdown, key rotation, and system reboots |

234
plan/plan.md Normal file
View File

@@ -0,0 +1,234 @@
# Watchman — Product Plan and Roadmap
Last updated: 2025-09-17 10:54 local
This document lays out where Watchman is today, the features we will add next, and a pragmatic, incremental path to get there. It complements the existing planning notes in:
- plan/features.md — initial Free vs. Paid feature thinking
- plan/monetization.md — high-level monetization comparison
The focus here is on technical scope, sequence, and acceptance criteria.
## 1) Vision and Principles
Watchman provides multi-signal host monitoring and automated response to help detect and mitigate unauthorized access or suspicious changes on a system. Key principles:
- Defense-in-depth: multiple independent signals with clear provenance.
- Actionable by default: every detection can notify and/or trigger a safe, reversible response.
- Secure by default: hardened runtime, least-privilege, safe config parsing.
- Observability: clear logs, metrics, and health checks.
- Cross-platform where feasible; optimize for Linux first.
## 2) Current State (v0.2.x)
Already in the repository (per README and codebase):
- Monitors
- USB activity: detect increases in connected USB devices; optional unmount/reboot/notify.
- File system integrity: hash-based monitoring for specified directories (e.g., /etc, /bin, $PATH) using blake3.
- Network: detect network issues/failures.
- SSH “burn file”: remote path check that can trigger protective actions.
- Notification: Discord webhook alerts.
- Config: Settings.toml with intervals, toggles, directories, webhook URL.
- Runtime: Rust async, uses tokio/actix; prometheus crate is present.
Gaps
- Metrics exist in codebase structure but not fully surfaced/standardized.
- Limited notification channels (Discord only).
- Validation of config values and schema evolution not formalized.
- Tests are limited. No fuzzing or integration test harness.
- Cross-platform support unclear (Linux focused).
- Service management, packaging, and upgrade story can be improved.
## 3) Near-Term Goals (security and reliability first)
- Hardening: minimize privileges, sanitize command execution, robust error handling.
- Deterministic behavior: clear state machine for each monitor, idempotent actions.
- Observability: consistent metrics and structured logs for every monitor and action.
- Safer configuration: validation, defaults, and deprecation path.
## 4) Feature Roadmap (Phased)
The roadmap uses small, shippable milestones. Each item lists key deliverables and acceptance criteria.
### Milestone A — 0.3.0: Observability & Config Hygiene
- Prometheus metrics v1
- Counters for events by monitor (usb_events_total, fs_events_total, net_failures_total, burn_events_total)
- Gauges for current connected USB count, last_success_timestamp per monitor
- Histogram for monitor latency and action execution duration
- /metrics endpoint gated by bind address (default: localhost) and optional auth token
- Acceptance: metrics documented in README; basic dashboard example provided.
- Structured logging
- Add consistent, machine-parseable logs (e.g., JSON via env-controlled format)
- Include correlation IDs per tick/incident
- Acceptance: toggle via config; example jq commands in README
- Config validation
- Strongly-typed Settings with explicit defaults
- Startup validation with actionable error messages
- Acceptance: invalid config causes non-zero exit with guidance; sample Settings.toml updated
### Milestone B — 0.3.1: Notification Channels v2
- Add Slack and generic webhook support
- Pluggable notifier trait; Discord refactored to use it
- Basic rate limiting and de-dup window to avoid alert storms
- Acceptance: e2e tests that simulate events and assert notifier behavior
### Milestone C — 0.3.2: Filesystem Integrity v2
- Baseline management (init, refresh, compare)
- Exclude/include patterns; per-path policies
- Optional immutable baseline storage (append-only file) and checksum manifest
- Acceptance: CLI subcommands watchman fs init|refresh|diff and docs
### Milestone D — 0.3.3: USB Monitor v2
- Trusted device allowlist (vendor:product IDs) and policy actions (alert-only, unmount, power-cycle where supported)
- Cooldown and backoff to avoid flapping
- Acceptance: simulator/test harness to inject fake USB events
### Milestone E — 0.3.4: Network Monitor v2
- Multiple targets, success quorum, and jittered intervals
- Optional traceroute-on-failure (best effort)
- Acceptance: integration tests with a local dummy target
### Milestone F — 0.4.0: Response Actions Framework
- Action registry (unmount, reboot, kill-process, rotate-keys [stub], run-script)
- Pre- and post-conditions; dry-run mode; per-action timeouts
- Acceptance: action plans expressed in config; unit tests for each action
### Milestone G — 0.4.1: Health and Control API
- HTTP control plane (bind-local by default):
- GET /healthz, /readyz, /metrics
- POST /actions/{name} with dry-run and audit
- Acceptance: integration tests; example curl commands in README
### Milestone H — 0.4.2: Packaging & Service
- Systemd unit hardening (CapabilityBoundingSet, NoNewPrivileges, ProtectSystem, etc.)
- Deb/RPM packaging; Homebrew Tap formula for macOS CLI-only
- Acceptance: reproducible binaries, install docs, and service hardening checklist
### Milestone I — 0.5.x: Cross-Platform & Extensibility
- macOS: fs events via FSEvents; limited USB visibility; launchd plist
- Windows: filesystem via USN Journal (scoped); service integration; USB capabilities evaluated
- Plugin interface (WASM or dynamic dispatch) for third-party monitors
- Acceptance: CI builds for Linux/macOS; Windows nightly artifacts
Note: If monetization becomes relevant later, see plan/features.md and plan/monetization.md for an initial split. We will keep the open-source core useful and secure.
## 5) Architecture Notes
- Monitors
- Each monitor is an independent component with a common trait: poll(), emit events, and register metrics.
- Monitors should be stateless across ticks where possible; persisted state is explicit (e.g., fs baseline).
- Event Bus
- Internally, use a channel-based event bus to decouple detection from actions/notifications.
- Events carry severity, source, timestamp, and correlation ID.
- Actions
- Declarative action specifications (conditions + operations). Support dry-run.
- Notifiers
- Implement a notifier trait with backoff, jitter, and rate-limiting wrappers.
- Configuration
- Strongly typed, documented, validated; feature flags guarded with clear defaults.
## 6) Configuration Evolution (draft)
Add or refine keys in config/Settings.toml:
- [general]
- tick_delay_seconds, fs_tick_delay_seconds
- log_format = "text"|"json"
- metrics_bind_addr = "127.0.0.1:9898"
- metrics_auth_token = "" # optional
- [notifications]
- discord_webhook_url = "..." (existing)
- slack_webhook_url = "..."
- generic_webhook_url = "..."
- notify_dedupe_window_secs = 30
- [fs]
- enabled = true
- dirs = ["/etc", "/bin", "$PATH"]
- exclude = ["/etc/ssl/**"]
- baseline_path = "/var/lib/watchman/fs-baseline.json"
- hash = "blake3"
- [usb]
- enabled = true
- allowlist = ["abcd:1234", "1d6b:0002"]
- action_on_unauthorized = "alert|unmount|reboot|none"
- cooldown_secs = 10
- [net]
- enabled = false
- targets = ["1.1.1.1", "8.8.8.8"]
- quorum = 1
- interval_jitter_pct = 20
- [burn]
- enabled = false
- ssh_host = "hostname"
- ssh_user = "root"
- ssh_key = "/home/user/.ssh/id_rsa"
- path = "/root/.config/burn"
- check_interval_secs = 30
## 7) Security & Hardening Checklist
- Run as dedicated user; least filesystem permissions.
- Systemd hardening options enabled by default.
- Validate and sanitize all external inputs (paths, webhook URLs, command args).
- Cryptographic hashing via blake3; check for algorithm agility hooks.
- Protect secrets in memory where possible; avoid logging sensitive values.
- Optional signed baseline and config (future).
## 8) Observability & Diagnostics
- Prometheus metrics as in Milestone A.
- Structured logs with correlation IDs.
- Debug bundle command: watchman diag bundle → collects versions, last logs, metrics snapshot (no secrets).
## 9) CLI and UX
- watchman run — start monitors with current config.
- watchman fs init|refresh|diff — manage filesystem baseline.
- watchman validate — validate config and report errors.
- watchman install-service webhook=<url> — keep for quick start; document flags.
## 10) Testing Strategy
- Unit tests per monitor and notifier.
- Integration tests spawning the binary with temp configs.
- Property-based tests for config parsing/validation.
- Fuzz critical parsers (USB sysfs parsing, config loader).
- Golden tests for logs/metrics outputs.
- CI: run clippy, fmt, tests; produce artifacts for Linux (and later macOS/Windows).
## 11) Performance Targets
- Idle memory: < 150 MB typical on Linux with all monitors enabled.
- CPU: < 2% average on a modern 2-core VM at default intervals.
- Baseline diff for 100k files: < 30s on SSD; incremental checks amortized.
## 12) Risks and Mitigations
- False positives driving destructive actions default to alert-only; require explicit opt-in for destructive actions; dry-run support.
- Platform differences (USB, FS APIs) feature gating; per-OS implementations.
- Metric/telemetry exposure bind-local by default; optional token; document firewalling.
## 13) Release Cadence and Versioning
- Minor iterations every 24 weeks; patch releases as needed.
- Semantic versioning: 0.x minor bumps may include breaking changes, documented in CHANGELOG.
## 14) Immediate Next Steps (for maintainers)
1. Define metric names and add minimal /metrics endpoint (A).
2. Add config validation with friendly errors (A).
3. Introduce notifier trait and migrate Discord (B).
4. Draft fs baseline CLI skeleton (C).
Track progress via issues and link them back to this plan.

2
run.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
cd /var/lib/watchman && ./watchman

21
src/arguments.rs Normal file
View File

@@ -0,0 +1,21 @@
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
#[derive(Subcommand, Deserialize, Serialize, Debug)]
pub enum Mode {
Standard
}
#[derive(Parser, Deserialize, Serialize, Debug)]
#[command(version)]
pub struct Arguments {
#[clap(subcommand)]
pub command: Option<Mode>,
#[arg(short, long)]
pub config_path: Option<String>,
#[arg(short, long)]
pub webhook_url: Option<String>,
#[arg(short, long)]
pub service: Option<String>,
}

59
src/config/linux.rs Normal file
View File

@@ -0,0 +1,59 @@
use std::{fs, process};
pub fn configure_linux_service() {
write_service_file("/etc/systemd/system/watchman.service");
if let Ok(o) = process::Command::new("systemctl")
.arg("daemon-reload")
.output()
{
println!("{}", String::from_utf8_lossy(&o.stdout));
if let Ok(o) = process::Command::new("systemctl")
.arg("enable")
.arg("watchman")
.output()
{
println!("{}", String::from_utf8_lossy(&o.stdout));
if let Ok(o) = process::Command::new("systemctl")
.arg("restart")
.arg("watchman")
.output()
{
println!("{}", String::from_utf8_lossy(&o.stdout));
}
}
}
}
pub fn write_service_file<T: ToString>(path: T) {
if whoami::username().eq_ignore_ascii_case("root") {
if fs::write(path.to_string(), service_file()).is_ok() {
println!(
"{}\n ~~~~~~~ SERVICE CONFIG CREATED ~~~~~~~",
service_file()
);
} else {
println!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE");
}
} else {
println!(
"{}",
"PLEASE RUN AS ROOT - COULD NOT WRITE SERVICE CONFIG TO FILE"
);
}
}
fn service_file() -> &'static str {
r#"
[Unit]
Description=Watchman
[Service]
Type=simple
User=root
Group=root
ExecStart=/root/.cargo/bin/watchman
[Install]
WantedBy=multi-user.target
"#
}

85
src/config/macos.rs Normal file
View File

@@ -0,0 +1,85 @@
use std::{fs, process};
pub fn configure_macos_service() {
write_launch_file("/Library/LaunchDaemons/com.helloimalemur.watchman.plist");
if let Ok(o) = process::Command::new("launchctl")
.arg("load")
.arg("/Library/LaunchDaemons/com.helloimalemur.watchman.plist")
.output()
{
println!("{}", String::from_utf8_lossy(&o.stdout));
if let Ok(o) = process::Command::new("launchctl")
.arg("start")
.arg("com.helloimalemur.watchman")
.output()
{
println!("{}", String::from_utf8_lossy(&o.stdout));
// if let Ok(o) = process::Command::new("systemctl")
// .arg("restart")
// .arg("watchman")
// .output()
// {
// println!("{}", String::from_utf8_lossy(&o.stdout));
// }
}
}
}
pub fn write_launch_file<T: ToString>(path: T) {
if whoami::username().eq_ignore_ascii_case("root") {
if fs::write(path.to_string(), launch_file()).is_ok() {
println!(
"{}\n ~~~~~~~ SERVICE CONFIG CREATED ~~~~~~~",
launch_file()
);
// sudo chown root:wheel /Library/LaunchDaemons/com.yourname.yourapp.plist
// sudo chmod 644 /Library/LaunchDaemons/com.yourname.yourapp.plist
let _ = process::Command::new("chown")
.arg("root:wheel")
.arg(path.to_string())
.spawn();
let _ = process::Command::new("chmod")
.arg("644")
.arg(path.to_string())
.spawn();
} else {
println!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE");
}
} else {
println!(
"{}",
"PLEASE RUN AS ROOT - COULD NOT WRITE SERVICE CONFIG TO FILE"
);
}
}
fn launch_file() -> &'static str {
r#"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.helloimalemur.watchman</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/watchman</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/watchman.log</string>
<key>StandardErrorPath</key>
<string>/var/log/watchman_errors.log</string>
</dict>
</plist>
"#
}

21
src/config/mod.rs Normal file
View File

@@ -0,0 +1,21 @@
use std::env::consts::OS;
use std::process;
use crate::config::linux::{configure_linux_service, write_service_file};
use crate::config::macos::configure_macos_service;
mod linux;
mod macos;
pub fn setup_service() {
match OS {
"linux" => {
configure_linux_service();
}
"macos" => {
configure_macos_service();
}
_ => {
println!("WARNING: not implemented for {}", OS);
}
}
}

193
src/fortiwatch.rs Normal file
View File

@@ -0,0 +1,193 @@
use crate::monitors::devices::USBMon;
use crate::monitors::filechanges::FileChanges;
// use crate::monitors::network::NETMon;
use crate::monitors::ssh_burn_file::SSHBurnMon;
use crate::monitors::clients::NewClientMon;
use config::Config;
use std::path::Path;
use std::process::exit;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use crate::arguments::Arguments;
use crate::config::setup_service;
use crate::util::{get_cache_dir, get_config_dir, write_default_blacklist, write_default_config};
pub trait EventMonitor {
async fn check(&mut self);
}
#[derive(Debug)]
pub enum Monitors {
USBMon(USBMon),
// NetMon(NETMon),
SSHBurnMon(SSHBurnMon),
FileChanges(FileChanges),
NewClients(NewClientMon),
}
pub struct AppState {
pub mon_usb: bool,
pub detection_triggered: bool,
pub monitors: Arc<Mutex<Vec<Monitors>>>,
pub settings_map: Config,
}
impl AppState {
pub fn new() -> Self {
AppState {
mon_usb: true,
detection_triggered: false,
monitors: Arc::new(Mutex::new(vec![])),
settings_map: Config::default(),
}
}
pub fn config(&mut self, args: Arguments) {
// check for config file if it doesn't exist write default config
println!("Configuring..");
let cache_dir = get_cache_dir();
let config_dir = get_config_dir();
let mut settings_file_path = format!("{}config/settings.toml", config_dir);
let mut webhook = String::new();
if let Some(wh) = args.webhook_url {
webhook = wh
}
if let Some(service_command) = args.service {
match service_command.as_str() {
"install" => {
println!("Installing..");
setup_service();
if !Path::new(settings_file_path.as_str()).exists() {
println!("Settings.toml does not exist");
crate::util::write_default_config(settings_file_path.clone(), webhook.clone());
exit(0);
}
}
_ => {}
}
}
if let Some(config_path) = args.config_path {
settings_file_path = config_path.to_string();
}
if !Path::new(settings_file_path.as_str()).exists() {
println!("Settings.toml does not exist");
write_default_config(settings_file_path.clone(), webhook);
}
let blacklist_file_path = format!("{}config/file_mon_blacklist", config_dir);
if !Path::new(blacklist_file_path.as_str()).exists() {
println!("file_mon_blacklist does not exist");
write_default_blacklist(blacklist_file_path.clone());
}
let config = Config::builder();
let settings = config
.add_source(config::File::with_name(settings_file_path.as_str()))
.build()
.expect("Configuration error: unable to parse settings");
// initialize monitoring modules
let mut monitors: Vec<Monitors> = vec![];
if settings
.get::<String>("usb_mon_enabled")
.unwrap()
.eq_ignore_ascii_case("true")
{
monitors.push(Monitors::USBMon(USBMon::new(settings.clone())));
}
// if settings
// .get::<String>("net_mon_enabled")
// .unwrap()
// .eq_ignore_ascii_case("true")
// {
// monitors.push(Monitors::NetMon(NETMon::new(settings.clone())));
// }
if settings
.get::<String>("burn_file_mon_enabled")
.unwrap()
.eq_ignore_ascii_case("true")
{
monitors.push(Monitors::SSHBurnMon(SSHBurnMon::new(settings.clone())));
}
if settings
.get::<String>("fs_mon_enabled")
.unwrap()
.eq_ignore_ascii_case("true")
{
monitors.push(Monitors::FileChanges(FileChanges::new(
settings.clone(),
blacklist_file_path,
cache_dir,
)));
}
// Enable new client scanning monitor if configured
if settings
.get::<String>("client_scan_enabled")
.unwrap_or("false".to_string())
.eq_ignore_ascii_case("true")
{
monitors.push(Monitors::NewClients(NewClientMon::new(settings.clone())));
}
self.monitors.clone_from(&Arc::new(Mutex::new(monitors)));
self.settings_map.clone_from(&settings)
}
pub async fn run(&mut self) {
let fs_check_tick = self
.settings_map
.get::<String>("fs_tick_delay_seconds")
.expect("tick_delay_seconds not found in Settings.toml");
let n_fs_check_tick = fs_check_tick
.parse::<u64>()
.expect("unable to parse fs_tick_delay_seconds");
let tick = self
.settings_map
.get::<String>("tick_delay_seconds")
.expect("tick_delay_seconds not found in Settings.toml");
let n_tick = tick
.parse::<u64>()
.expect("unable to parse tick_delay_seconds");
let mut last = SystemTime::now();
loop {
let mut binding = self.monitors.lock();
let bind = binding.as_mut().unwrap();
for i in bind.iter_mut() {
match i {
Monitors::USBMon(e) => {
// println!("{:#?}", e);
e.check().await;
}
// Monitors::NetMon(e) => {
// // println!("{:#?}", e);
// e.check().await;
// }
Monitors::SSHBurnMon(e) => {
// println!("{:#?}", e);
e.check().await;
}
Monitors::FileChanges(e) => {
// println!("{:#?}", e);
let now = SystemTime::now();
let dur_since = now.duration_since(last).unwrap();
if dur_since.as_secs() > n_fs_check_tick {
// println!("fs_tick");
last = SystemTime::now();
e.check().await;
}
}
Monitors::NewClients(e) => {
// println!("{:#?}", e);
e.check().await;
}
}
tokio::time::sleep(Duration::new(n_tick, 0)).await;
// println!("tick");
}
}
}
}

23
src/main.rs Normal file
View File

@@ -0,0 +1,23 @@
use std::env::consts::OS;
use clap::Parser;
use crate::arguments::Arguments;
use crate::fortiwatch::AppState;
use crate::prometheus::Prometheus;
mod fortiwatch;
mod monitors;
mod prometheus;
mod arguments;
mod config;
mod util;
#[tokio::main]
async fn main() {
println!("OS: {}", OS);
let arguments = Arguments::parse();
let mut prom = Prometheus::new();
prom.start().await;
let mut app = AppState::new();
app.config(arguments);
app.run().await;
}

View File

@@ -0,0 +1,82 @@
use std::env::consts::OS;
use crate::monitors::notify::send_discord;
use config::Config;
use std::process::Command;
use std::thread;
use std::time::Duration;
pub async fn reboot_system(settings_map: Config) {
let _res = send_discord("System rebooting", settings_map, &"".to_string()).await;
if let Ok(reboot) = Command::new("reboot").output() {
println!("{:#?}", reboot)
}
}
pub async fn unmount_encrypted_volumes() {
match OS {
"linux" => {
if let Ok(output) = Command::new("dmsetup").arg("ls").output() {
let encrypted_vols = String::from_utf8(output.stdout.to_vec()).unwrap();
for ea in encrypted_vols.lines() {
let vol_split: Vec<_> = ea.split_ascii_whitespace().collect();
let luks_vol = vol_split.first().unwrap().to_string();
// println!("{}", luks_vol);
// println!("{}", command_str);
if let Ok(res) = Command::new("cat").arg("/proc/mounts").output() {
let mount_file_results = String::from_utf8(res.stdout.to_vec()).unwrap();
for mount in mount_file_results.lines() {
let luks_vol_str = luks_vol.as_str();
if mount.contains(luks_vol_str) {
let mount_split: Vec<&str> = mount.split_ascii_whitespace().collect();
let vol_mount_path = mount_split.get(1).unwrap();
// println!("{}", vol_mount_path);
commit_umount_volume(vol_mount_path);
thread::sleep(Duration::new(1, 0));
commit_luks_close(luks_vol_str);
}
}
}
}
}
}
_ => {}
}
}
fn commit_umount_volume(vol_mount_path: &str) {
match OS {
"linux" => {
if let Ok(res) = Command::new("umount").arg(vol_mount_path).output() {
println!("{}", String::from_utf8(res.stdout.to_vec()).unwrap())
}
}
_ => {}
}
}
fn commit_luks_close(luks_vol: &str) {
match OS {
"linux" => {
println!("close luks");
if let Ok(res) = Command::new("cryptsetup")
.arg("luksClose")
.arg(luks_vol)
.output()
{
println!("{}", String::from_utf8(res.stdout.to_vec()).unwrap())
}
}
_ => {}
}
}
// #[cfg(test)]
// mod tests {
// use crate::monitors::actions::unmount_encrypted_volumes;
//
// #[test]
// fn test_unmount_encrypted() {
// let rt = tokio::runtime::Runtime::new();
// rt.unwrap().block_on(unmount_encrypted_volumes());
// }
// }

296
src/monitors/clients.rs Normal file
View File

@@ -0,0 +1,296 @@
use std::collections::{HashMap, HashSet};
use std::process::Command;
use std::time::{Duration, SystemTime};
use std::env::consts::OS;
use crate::fortiwatch::EventMonitor;
use crate::monitors::actions::reboot_system;
use crate::monitors::notify::send_discord;
use anyhow::anyhow;
use config::Config;
use lazy_static::lazy_static;
use prometheus::{register_int_counter, IntCounter};
lazy_static! {
pub static ref SCANS_TOTAL: IntCounter = register_int_counter!(
"watchman_scans_total",
"Total client scans run"
)
.unwrap();
pub static ref FINDINGS_TOTAL: IntCounter = register_int_counter!(
"watchman_findings_total",
"Total scan findings"
)
.unwrap();
pub static ref ACTIONS_TOTAL: IntCounter = register_int_counter!(
"watchman_actions_total",
"Total actions taken on findings"
)
.unwrap();
pub static ref SCANNER_ERRORS_TOTAL: IntCounter = register_int_counter!(
"watchman_scanner_errors_total",
"Total scanner errors"
)
.unwrap();
}
#[derive(Debug)]
pub struct NewClientMon {
settings_map: Config,
known_clients: HashSet<String>,
last_seen: HashMap<String, SystemTime>,
debounce_secs: u64,
}
impl NewClientMon {
pub fn new(settings_map: Config) -> Self {
NewClientMon {
settings_map,
known_clients: HashSet::new(),
last_seen: HashMap::new(),
debounce_secs: 600, // 10 minutes debounce
}
}
}
impl EventMonitor for NewClientMon {
async fn check(&mut self) {
let discovery_method = self
.settings_map
.get::<String>("client_discovery_method")
.unwrap_or("arp".to_string());
let exclusions: Vec<String> = self
.settings_map
.get::<Vec<String>>("client_scan_exclusions")
.unwrap_or(vec![]);
let cur_clients = match discovery_method.as_str() {
"arp" => discover_via_arp(),
// Future: add ping sweep
_ => discover_via_arp(),
};
if cur_clients.is_empty() {
return;
}
let now = SystemTime::now();
for ip in cur_clients {
if exclusions.iter().any(|e| e == &ip) {
continue;
}
let recently_seen = self
.last_seen
.get(&ip)
.and_then(|t| now.duration_since(*t).ok())
.map(|d| d.as_secs() < self.debounce_secs)
.unwrap_or(false);
if !self.known_clients.contains(&ip) || !recently_seen {
self.known_clients.insert(ip.clone());
self.last_seen.insert(ip.clone(), now);
// Trigger scan on add if enabled
let scan_on_add = self
.settings_map
.get::<String>("scan_on_add")
.unwrap_or("true".to_string())
.eq_ignore_ascii_case("true");
if scan_on_add {
if let Err(e) = scan_and_evaluate(ip.clone(), self.settings_map.clone()).await {
eprintln!("scan error: {:?}", e);
SCANNER_ERRORS_TOTAL.inc();
}
}
}
}
}
}
fn discover_via_arp() -> Vec<String> {
let mut ips: Vec<String> = vec![];
match OS {
"windows" => {
if let Ok(out) = Command::new("arp").arg("-a").output() {
let s = String::from_utf8_lossy(&out.stdout).to_string();
for line in s.lines() {
if let Some(ip) = extract_ip(line) {
if !ips.contains(&ip) {
ips.push(ip);
}
}
}
}
}
"macos" => {
if let Ok(out) = Command::new("arp").arg("-an").output() {
let s = String::from_utf8_lossy(&out.stdout).to_string();
for line in s.lines() {
if let Some(ip) = extract_ip(line) {
if !ips.contains(&ip) {
ips.push(ip);
}
}
}
} else if let Ok(out) = Command::new("arp").arg("-a").output() {
let s = String::from_utf8_lossy(&out.stdout).to_string();
for line in s.lines() {
if let Some(ip) = extract_ip(line) {
if !ips.contains(&ip) {
ips.push(ip);
}
}
}
}
}
_ => {
if let Ok(out) = Command::new("ip").arg("neigh").output() {
let s = String::from_utf8_lossy(&out.stdout).to_string();
for line in s.lines() {
if let Some(ip) = extract_ip(line) {
if !ips.contains(&ip) {
ips.push(ip);
}
}
}
} else if let Ok(out) = Command::new("arp").arg("-an").output() {
let s = String::from_utf8_lossy(&out.stdout).to_string();
for line in s.lines() {
if let Some(ip) = extract_ip(line) {
if !ips.contains(&ip) {
ips.push(ip);
}
}
}
}
}
}
ips
}
fn extract_ip(line: &str) -> Option<String> {
// naive parse for dotted IPv4
let mut cur = String::new();
let mut found = None;
for tok in line.split(|c: char| c.is_whitespace() || c == '(' || c == ')' ) {
if tok.chars().filter(|c| *c == '.').count() == 3 {
// simple check
cur = tok.to_string();
if cur.chars().all(|c| c.is_ascii_digit() || c == '.') {
found = Some(cur.clone());
break;
}
}
}
found
}
async fn scan_and_evaluate(ip: String, settings_map: Config) -> Result<(), anyhow::Error> {
SCANS_TOTAL.inc();
let nmap_path = settings_map
.get::<String>("nmap_path")
.unwrap_or("nmap".to_string());
let nmap_profile = settings_map
.get::<String>("nmap_profile")
.unwrap_or("-sV -T4".to_string());
// Build command: nmap [profile] -oX - <ip> without using a shell for cross-platform compatibility
let mut cmd = Command::new(nmap_path);
for arg in nmap_profile.split_whitespace() {
if !arg.is_empty() { cmd.arg(arg); }
}
cmd.arg("-oX").arg("-").arg(&ip);
let output = cmd.output()?;
if !output.status.success() {
SCANNER_ERRORS_TOTAL.inc();
return Err(anyhow!("nmap failed for {}", ip));
}
let xml = String::from_utf8_lossy(&output.stdout).to_string();
let open_ports = parse_open_ports_from_nmap_xml(&xml);
let critical_ports: Vec<String> = settings_map
.get::<Vec<String>>("findings_critical_ports")
.unwrap_or(vec!["23".to_string(), "2323".to_string(), "3389".to_string(), "5900".to_string()]);
let mut critical_hits: Vec<String> = vec![];
for p in &open_ports {
if critical_ports.iter().any(|c| c == p) {
critical_hits.push(p.clone());
}
}
let notify = settings_map
.get::<String>("notify_on_findings")
.unwrap_or("false".to_string())
.eq_ignore_ascii_case("true");
if !open_ports.is_empty() {
FINDINGS_TOTAL.inc();
let summary = format!(
"New client {} scanned. Open ports: {}. Critical: {}",
ip,
open_ports.join(","),
if critical_hits.is_empty() {
"none".to_string()
} else {
critical_hits.join(",")
}
);
if notify {
let _ = send_discord("Client scan findings", settings_map.clone(), &summary).await;
}
let action = settings_map
.get::<String>("action_on_findings")
.unwrap_or("none".to_string())
.to_lowercase();
match action.as_str() {
"reboot" => {
ACTIONS_TOTAL.inc();
reboot_system(settings_map.clone()).await;
}
_ => {}
}
}
Ok(())
}
fn parse_open_ports_from_nmap_xml(xml: &str) -> Vec<String> {
// Very naive XML parsing: look for <port portid="NNN"> ... <state state="open" .../>
let mut ports: Vec<String> = vec![];
let bytes = xml.as_bytes();
let mut i = 0;
while let Some(port_idx) = find_sub(bytes, i, b"<port ") {
if let Some(portid_idx) = find_sub(bytes, port_idx, b"portid=\"") {
let start = portid_idx + 8;
if let Some(end) = find_quote(bytes, start) {
let portid = &xml[start..end];
// search for state="open" before closing port tag
if let Some(state_idx) = find_sub(bytes, portid_idx, b"state=\"open\"") {
if let Some(close_idx) = find_sub(bytes, portid_idx, b"</port>") {
if state_idx < close_idx {
ports.push(portid.to_string());
}
}
}
i = end;
continue;
}
}
i = port_idx + 6;
}
ports
}
fn find_sub(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
fn find_quote(hay: &[u8], from: usize) -> Option<usize> {
for i in from..hay.len() {
if hay[i] == b'"' { return Some(i); }
}
None
}

182
src/monitors/devices.rs Normal file
View File

@@ -0,0 +1,182 @@
use std::env::consts::OS;
use crate::fortiwatch::EventMonitor;
use crate::monitors::actions::reboot_system;
use crate::monitors::notify::send_discord;
use chrono::Local;
use config::Config;
use std::process::{exit, Command};
use std::fs;
#[derive(Debug)]
pub struct USBMon {
triggered: bool,
devices: Vec<String>,
total_devices: usize,
last_check: usize,
settings_map: Config,
}
impl USBMon {
pub fn new(settings_map: Config) -> Self {
USBMon {
triggered: false,
devices: vec![],
total_devices: 0,
last_check: 0,
settings_map,
}
}
}
impl EventMonitor for USBMon {
async fn check(&mut self) {
let mut dev_change = String::new();
let mut new_devices: Vec<String> = vec![];
let n = get_usb_devices_physical().await;
n.iter().for_each(|r| new_devices.push(r.to_string()));
let d = get_usb_devices().await;
d.iter().for_each(|r| new_devices.push(r.to_string()));
if self.last_check != 0 && self.last_check != new_devices.len() {
self.total_devices = self.devices.len();
match self.last_check < new_devices.len() {
true => {
for entry in new_devices.iter() {
if !self.devices.contains(&entry) {
dev_change = entry.clone();
}
}
self.triggered = true;
println!(
"{} :: Total USB devices INCREASED: {} :: {}",
Local::now(),
self.total_devices,
&dev_change
);
}
false => {
for entry in self.devices.iter() {
if !new_devices.contains(&entry) {
dev_change = entry.clone();
}
}
println!(
"{} :: Total USB devices DECREASED: {} :: {}",
Local::now(),
self.total_devices,
&dev_change
);
}
}
self.devices.clone_from(&new_devices);
self.last_check = self.total_devices;
} else if self.last_check == 0 {
self.devices.clone_from(&new_devices);
self.total_devices = new_devices.len();
self.last_check = self.total_devices;
println!("Starting..");
println!(
"{} :: Total USB devices: {}",
Local::now(),
self.total_devices
);
}
// println!(
// "check usb: {}, count: {}",
// self.triggered, self.total_devices
// );
if self.triggered {
println!("ALERT USB");
usb_triggered(self.settings_map.clone(), &dev_change).await;
println!(
"{} :: USB count: {} :: {}",
Local::now(),
self.total_devices,
&dev_change
);
self.triggered = false;
}
}
}
async fn get_usb_devices() -> Vec<String> {
let mut devices: Vec<String> = vec![];
match OS {
"linux" => {
if let Ok(res) = Command::new("lsusb").output() {
if let Ok(result) = String::from_utf8(res.stdout.to_vec()) {
for r in result.lines() {
if !r.trim().is_empty() {
devices.push(r.trim().to_string());
}
}
}
}
}
"macos" => {
if let Ok(res) = Command::new("system_profiler").arg("SPUSBDataType").output() {
if let Ok(result) = String::from_utf8(res.stdout.to_vec()) {
for r in result.lines() {
if !r.trim().is_empty() {
devices.push(r.trim().to_string());
}
}
}
}
}
_ => {
// Unsupported platform (e.g., Windows): return empty list for safety
}
}
devices
}
async fn get_usb_devices_physical() -> Vec<String> {
let mut devices: Vec<String> = vec![];
match OS {
"linux" => {
if let Ok(contents) = fs::read_to_string("/proc/bus/input/devices") {
for line in contents.lines() {
if line.trim_start().starts_with("S:") {
devices.push(line.to_string());
}
}
}
}
"macos" => {
if let Ok(res) = Command::new("ioreg").args(["-n", "IOHIDSystem", "-r"]).output() {
if let Ok(result) = String::from_utf8(res.stdout.to_vec()) {
for r in result.lines() {
if !r.trim().is_empty() {
devices.push(r.trim().to_string());
}
}
}
}
}
_ => {
// Unsupported platform (e.g., Windows): return empty list for safety
}
}
devices
}
async fn usb_triggered(settings_map: Config, dev_change: &String) {
if settings_map
.get::<String>("reboot_on_increase_of_usb_devices")
.unwrap_or("false".to_string())
.eq_ignore_ascii_case("true")
{
reboot_system(settings_map.clone()).await;
}
if settings_map
.get::<String>("notify_on_increase_of_usb_devices")
.unwrap_or("false".to_string())
.eq_ignore_ascii_case("true")
{
let _ = send_discord("USB triggered", settings_map.clone(), &dev_change).await;
}
}

327
src/monitors/filechanges.rs Normal file
View File

@@ -0,0 +1,327 @@
use crate::fortiwatch::EventMonitor;
use crate::monitors::notify::send_discord;
use chrono::Local;
use config::Config;
use filesystem_hashing::hasher::HashType;
use filesystem_hashing::snapshot::Snapshot;
use filesystem_hashing::{compare_snapshots, create_snapshot};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::{env, fs};
#[allow(unused)]
#[derive(Debug)]
pub struct FileChanges {
triggered: bool,
step: u16,
monitored_directories: Vec<String>,
snapshots: Vec<Snapshot>,
hash_type: HashType,
settings_map: Config,
black_list: Vec<String>,
app_cache_path: String,
}
impl FileChanges {
pub fn new(settings_map: Config, blacklist_file_path: String, app_cache_path: String) -> Self {
let mut file_changes = FileChanges {
triggered: false,
step: 0,
monitored_directories: load_directories(settings_map.clone()),
snapshots: vec![],
hash_type: get_hash_type(settings_map.clone()),
settings_map,
black_list: load_blacklist(blacklist_file_path),
app_cache_path,
};
// load and push blacklisted directories
println!("Blacklisted: {:?}", file_changes.black_list);
// file_changes.load_state();
if file_changes.snapshots.is_empty() {
if let Ok(b) = file_changes
.settings_map
.get::<bool>("fs_mon_path_variable")
{
if b {
let path = env::var("PATH").unwrap_or_else(|_| String::new());
let split = path.split(':').collect::<Vec<&str>>();
for s in split {
if !file_changes.black_list.contains(&s.to_string()) {
if let Ok(snapshot) = create_snapshot(
s,
HashType::BLAKE3,
file_changes.black_list.clone(),
false,
) {
file_changes.snapshots.push(snapshot);
}
}
}
}
}
for dir in &file_changes.monitored_directories {
if !file_changes.black_list.contains(dir) {
if let Ok(snapshot) = create_snapshot(
dir.as_str(),
HashType::BLAKE3,
file_changes.black_list.clone(),
false,
) {
file_changes.snapshots.push(snapshot);
}
}
}
let mut count = 0;
println!("File Count:");
file_changes.snapshots.iter().for_each(|s| {
let s_len = s.file_hashes.lock().unwrap().len();
println!("{} ---- {}", s_len, s.root_path);
count += s_len
});
// println!("{:#?}", file_changes.snapshots);
let message = format!(
"{} :: Filesystem Snapshot Creation Successful\n\nTotal files: {}\n",
Local::now(),
count
);
println!("{}", message);
}
file_changes.save_state();
file_changes
}
fn save_state(&mut self) {
let snapshots_path = format!("{}snapshots/", self.app_cache_path);
// println!("{}", snapshots_path);
let _ = fs::create_dir_all(Path::new(snapshots_path.as_str()));
self.snapshots.iter().for_each(|snapshot: &Snapshot| {
let root_path_hash = blake3::hash(snapshot.root_path.as_bytes()).to_string();
// println!("Exporting: {}", root_path_hash);
let path = format!("{}/snapshots/{}", self.app_cache_path, root_path_hash);
// println!("{:?}", snapshot);
let sn = Snapshot {
file_hashes: Arc::new(Mutex::new(snapshot.file_hashes.lock().unwrap().clone())),
black_list: snapshot.black_list.clone(),
root_path: snapshot.root_path.clone(),
hash_type: HashType::BLAKE3,
uuid: snapshot.uuid.clone(),
date_created: snapshot.date_created.clone(),
};
if filesystem_hashing::export_snapshot(sn, path, true, false).is_err() {
println!("WARNING: could not save state")
}
});
}
fn load_state(&mut self) {
let snapshots_path = format!("{}snapshots/", self.app_cache_path);
let mut snapshots: Vec<Snapshot> = vec![];
let mut count = 0;
if Path::new(snapshots_path.as_str()).exists() {
let dir_vec = walkdir::WalkDir::new(snapshots_path)
.contents_first(true)
.into_iter()
.map(|e| e.unwrap().path().to_str().unwrap().to_string())
.filter(|a| Path::new(a).is_file())
.collect::<Vec<String>>();
// println!("{:?}", dir_vec);
dir_vec.iter().for_each(|dir| {
println!("{}", dir);
let import = filesystem_hashing::import_snapshot(dir.to_string(), false).unwrap();
// println!("{:?}", import);
count += import.file_hashes.lock().unwrap().len();
snapshots.push(import)
});
self.snapshots.clone_from(&snapshots);
println!("State Loaded..{} files", count);
// drop(snapshots)
// println!("{:?}", dir_vec);
}
}
}
impl EventMonitor for FileChanges {
async fn check(&mut self) {
match compare_all_snapshots(self, self.settings_map.clone(), self.black_list.clone()).await
{
None => {
println!("...");
}
Some(e) => match e.0 {
SnapshotChangeType::None => {
// let message = format!("{} :: File System Unchanged",Local::now());
println!(".. no changes ..");
}
SnapshotChangeType::Created => {
// println!("{} :: File Created Alert!\n{:#?}", Local::now(), e.1);
let message = format!(
"{} :: File Creation Detected: {:?}",
Local::now(),
e.1.created
);
fs_changes_alert(message, self.settings_map.clone()).await
}
SnapshotChangeType::Deleted => {
// println!("{} :: File Deleted Alert!\n{:#?}", Local::now(), e.1);
let message = format!(
"{} :: File Deletion Detected: {:?}",
Local::now(),
e.1.deleted
);
fs_changes_alert(message, self.settings_map.clone()).await
}
SnapshotChangeType::Changed => {
// println!("{} :: File Change Alert!\n{:#?}", Local::now(), e.1);
let message = format!(
"{} :: File Change Detected: {:?}",
Local::now(),
e.1.changed
);
fs_changes_alert(message, self.settings_map.clone()).await
}
},
}
self.triggered = false;
self.save_state()
}
}
fn load_directories(settings_map: Config) -> Vec<String> {
let mut dirs: Vec<String> = vec![];
let mon_dirs = settings_map.get::<Vec<String>>("fs_mon_dir").unwrap();
for i in mon_dirs.iter() {
if i.contains('$') {
let env_var = i.replace('$', "");
let env_ret = env::var(env_var).unwrap();
let split = env_ret.split(':').collect::<Vec<&str>>();
split.iter().for_each(|e| dirs.push(e.to_string()))
} else {
dirs.push(i.to_string())
}
}
println!("Monitoring Directories: {:#?}", dirs);
dirs
}
fn get_hash_type(settings_map: Config) -> HashType {
match settings_map
.get::<String>("fs_mon_hash_type")
.unwrap()
.as_str()
{
"blake3" => HashType::BLAKE3,
"SHA3" => HashType::SHA3,
"MD5" => HashType::MD5,
_ => HashType::BLAKE3,
}
}
fn load_blacklist(blacklist_file_path: String) -> Vec<String> {
let mut black_list: Vec<String> = vec![];
if let Ok(file) = fs::read_to_string(Path::new(&blacklist_file_path)) {
for line in file.lines() {
if !line.is_empty() {
black_list.push(line.to_string());
}
}
}
black_list
}
enum SnapshotChangeType {
None,
Created,
Deleted,
Changed,
}
#[derive(Debug)]
pub struct SnapshotCompareResult {
pub created: Vec<String>,
pub deleted: Vec<String>,
pub changed: Vec<String>,
}
async fn compare_all_snapshots(
file_changes: &mut FileChanges,
_settings_map: Config,
black_list: Vec<String>,
) -> Option<(SnapshotChangeType, SnapshotCompareResult)> {
let mut created: Vec<String> = vec![];
let mut deleted: Vec<String> = vec![];
let mut changed: Vec<String> = vec![];
let mut to_remove: Vec<usize> = vec![];
let mut new_sn: Vec<Snapshot> = vec![];
for (ind, i) in file_changes.snapshots.iter().enumerate() {
// println!("{:#?}", black_list);
if let Ok(rehash) =
Snapshot::new(i.root_path.as_ref(), i.hash_type, black_list.clone(), false)
{
if let Some(res) = compare_snapshots(i.clone(), rehash.clone(), false) {
// println!("{}", i.root_path);
for c in res.1.created {
created.push(c)
}
for d in res.1.deleted {
deleted.push(d)
}
for ch in res.1.changed {
changed.push(ch)
}
to_remove.push(ind);
new_sn.push(rehash.clone());
}
}
}
file_changes.snapshots.clear();
file_changes.snapshots = new_sn;
let mut return_type = SnapshotChangeType::None;
if !created.is_empty() {
return_type = SnapshotChangeType::Created;
}
if !deleted.is_empty() {
return_type = SnapshotChangeType::Deleted;
}
if !changed.is_empty() {
return_type = SnapshotChangeType::Changed;
}
Some((
return_type,
SnapshotCompareResult {
created,
deleted,
changed,
},
))
}
async fn fs_changes_alert(message: String, settings_map: Config) {
println!("{}", message);
let _ = send_discord(message.as_str(), settings_map, &"".to_string()).await;
}
#[cfg(test)]
mod tests {
use crate::monitors::filechanges::load_blacklist;
#[test]
fn test_load_blacklist() {
let x = load_blacklist("".to_string());
println!("{:#?}", x);
}
}

7
src/monitors/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod actions;
pub mod devices;
pub mod filechanges;
pub mod network;
pub mod notify;
pub mod ssh_burn_file;
pub mod clients;

67
src/monitors/network.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::sync::Arc;
use crate::fortiwatch::EventMonitor;
use crate::monitors::actions::reboot_system;
use chrono::Local;
use config::Config;
use netstat::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, SocketInfo};
use tokio::sync::Mutex;
#[allow(unused)]
#[derive(Debug)]
pub struct NETMon {
triggered: bool,
interfaces: Vec<String>,
settings_map: Config,
state: Arc<Mutex<Vec<SocketInfo>>>
}
impl NETMon {
pub fn new(settings_map: Config) -> Self {
let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
let proto_flags = ProtocolFlags::TCP | ProtocolFlags::UDP;
let sockets_info = get_sockets_info(af_flags, proto_flags).unwrap();
let moved_sockets = sockets_info.clone();
for si in moved_sockets {
match si.protocol_socket_info {
ProtocolSocketInfo::Tcp(tcp_si) => println!(
"TCP {}:{} -> {}:{} {:?} - {}",
tcp_si.local_addr,
tcp_si.local_port,
tcp_si.remote_addr,
tcp_si.remote_port,
si.associated_pids,
tcp_si.state
),
ProtocolSocketInfo::Udp(udp_si) => println!(
"UDP {}:{} -> *:* {:?}",
udp_si.local_addr, udp_si.local_port, si.associated_pids
),
}
}
NETMon {
triggered: false,
interfaces: vec![],
settings_map,
state: Arc::new(Mutex::new(sockets_info.clone()))
}
}
}
impl EventMonitor for NETMon {
async fn check(&mut self) {
if let Ok(check) = httping::ping("koonts.net", "", "https", 443).await {
self.triggered = !check
}
if self.triggered {
println!("{} :: ALERT NET", Local::now());
net_alert(self.settings_map.clone()).await;
}
// println!("check net: {}", self.triggered);
}
}
async fn net_alert(settings_map: Config) {
reboot_system(settings_map).await;
}

View File

@@ -0,0 +1,87 @@
use std::env::consts::OS;
use config::Config;
use std::process::Command;
use anyhow::anyhow;
use discord_webhook_lib::DiscordMessage;
pub async fn send_discord(
message: &str,
settings_map: Config,
append: &String,
) -> Result<(), anyhow::Error> {
#[allow(unused)]
let mut final_message = String::new();
final_message = match OS {
"linux" => {
if let Ok(output) = Command::new("hostnamectl").arg("hostname").output() {
if let Ok(hostname) = String::from_utf8(output.stdout) {
format!("{} Hostname: {} :: {}", message, hostname.trim(), append)
} else {
format!("{} Hostname: unknown :: {}", message, append)
}
} else {
format!("{} Hostname: unknown :: {}", message, append)
}
},
"macos" => {
if let Ok(output) = Command::new("hostname").output() {
if let Ok(hostname) = String::from_utf8(output.stdout) {
format!("{} Hostname: {} :: {}", message, hostname.trim(), append)
} else {
format!("{} Hostname: unknown :: {}", message, append)
}
} else {
format!("{} Hostname: unknown :: {}", message, append)
}
}
_ => {
if let Ok(output) = Command::new("hostname").output() {
if let Ok(hostname) = String::from_utf8(output.stdout) {
format!("{} Hostname: {} :: {}", message, hostname.trim(), append)
} else {
format!("{} Hostname: unknown :: {}", message, append)
}
} else {
format!("{} Hostname: unknown :: {}", message, append)
}
}
};
// Default to no-op if webhook is not provided
let discord_webhook_url = settings_map
.get::<String>("discord_webhook_url")
.unwrap_or_default();
if discord_webhook_url.trim().is_empty() {
return Ok(()); // Disabled by default if not configured
}
let discord_webhook_avatar_name = settings_map
.get::<String>("discord_webhook_avatar_name")
.unwrap_or_else(|_| "Watchman".to_string());
let mut message = DiscordMessage::builder(discord_webhook_url);
message.add_message(final_message);
message.add_field("username", discord_webhook_avatar_name);
let final_message = message.build();
if final_message.send().await.is_ok() {
Ok(())
} else {
Err(anyhow!("Failed to send discord message"))
}
}
// pub async fn send_email() {}
// #[cfg(test)]
// mod tests {
// use discord_webhook_lib::send_discord;
//
// #[test]
// fn test_send_wh() {
// let rt = tokio::runtime::Runtime::new();
// rt.unwrap().block_on(send_discord(
// "",
// "Hello World",
// "Lazarus"
// )).unwrap();
// }
// }

View File

@@ -0,0 +1,84 @@
use crate::fortiwatch::EventMonitor;
use crate::monitors::actions::unmount_encrypted_volumes;
use chrono::{DateTime, Utc};
use config::Config;
use std::process::Command;
#[allow(unused)]
#[derive(Debug)]
pub struct SSHBurnMon {
triggered: bool,
settings_map: Config,
last_check: DateTime<Utc>,
}
impl SSHBurnMon {
pub fn new(settings_map: Config) -> Self {
SSHBurnMon {
triggered: false,
settings_map,
last_check: Utc::now(),
}
}
async fn ssh_burn_triggered(&self) {
if self
.settings_map
.get::<String>("unmount_crypt_on_file_burn")
.unwrap()
.eq_ignore_ascii_case("true")
{
unmount_encrypted_volumes().await;
}
}
}
impl EventMonitor for SSHBurnMon {
async fn check(&mut self) {
let ssh_check_burn_check_interval = self
.settings_map
.get::<String>("ssh_check_burn_check_interval")
.unwrap()
.parse::<i64>()
.unwrap();
if Utc::now()
.signed_duration_since(self.last_check)
.num_seconds()
> ssh_check_burn_check_interval
{
let ssh_check_burn_host = self
.settings_map
.get::<String>("ssh_check_burn_host")
.unwrap();
let ssh_check_burn_user = self
.settings_map
.get::<String>("ssh_check_burn_user")
.unwrap();
let ssh_check_burn_key = self
.settings_map
.get::<String>("ssh_check_burn_key")
.unwrap();
let ssh_check_burn_path = self
.settings_map
.get::<String>("ssh_check_burn_path")
.unwrap();
let addr = format!("{}@{}", ssh_check_burn_user, ssh_check_burn_host);
let command_str = format!(
"if [ -f {} ]; then cat {}; fi",
ssh_check_burn_path, ssh_check_burn_path
);
if let Ok(result) = Command::new("ssh")
.arg("-i")
.arg(ssh_check_burn_key)
.arg(addr)
.arg(command_str)
.output()
{
let burn_contents = String::from_utf8(result.stdout).unwrap();
if burn_contents.eq_ignore_ascii_case("burn") {
self.ssh_burn_triggered().await;
}
}
self.last_check = Utc::now();
}
}
}

45
src/prometheus/mod.rs Normal file
View File

@@ -0,0 +1,45 @@
use actix_web::http::header;
use actix_web::{get, web, App, Error, HttpResponse, HttpServer, Responder};
use prometheus::{Encoder, TextEncoder};
use std::thread;
pub struct Prometheus {}
impl Prometheus {
pub fn new() -> Self {
Prometheus {}
}
pub async fn start(&self) {
let _handle = thread::spawn(|| {
let rt = tokio::runtime::Runtime::new();
let _ = rt.unwrap().block_on(
HttpServer::new(|| App::new().service(metrics))
.bind(("127.0.0.1", 8323))
.unwrap()
.run(),
);
});
// let _ = handle.join();
}
}
#[get("/metrics")]
async fn metrics() -> Result<HttpResponse, Error> {
let encoder = TextEncoder::new();
// HTTP_COUNTER.inc();
// let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["all"]).start_timer();
let metric_families = prometheus::gather();
let mut buffer = vec![];
encoder.encode(&metric_families, &mut buffer).unwrap();
// HTTP_BODY_GAUGE.set(buffer.len() as f64);
let response = String::from_utf8(buffer.clone()).unwrap();
buffer.clear();
// timer.observe_duration();
Ok(HttpResponse::Ok()
.insert_header(header::ContentType(mime::TEXT_PLAIN))
.body(response))
}

224
src/util/mod.rs Normal file
View File

@@ -0,0 +1,224 @@
use std::env::consts::OS;
use std::fs;
use std::path::Path;
pub fn get_cache_dir() -> String {
#[allow(unused)]
let mut cache_dir = String::new();
let cur_user = whoami::username();
match OS {
"linux" => {
if cur_user.eq_ignore_ascii_case("root") {
let _ = fs::create_dir_all(Path::new("/root/.config/watchman/config/"));
"/root/.config/watchman/".to_string()
} else {
let create_dir = format!("/home/{}/.config/watchman/config/", cur_user);
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
format!("/home/{}/.config/watchman/", cur_user)
}
}
"macos" => {
// /var/root/Library/Caches/com.helloimalemur.watchman/
if cur_user.eq_ignore_ascii_case("root") {
let _ = fs::create_dir_all(Path::new("/var/root/Library/Caches/com.helloimalemur.watchman/config/"));
"/var/root/Library/Caches/com.helloimalemur.watchman/".to_string()
} else {
let create_dir = format!("/Users/{}/Library/Caches/com.helloimalemur.watchman/config/", cur_user);
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
format!("/Users/{}/Library/Caches/com.helloimalemur.watchman/", cur_user)
}
}
"windows" => {
// Prefer LOCALAPPDATA for cache
if let Ok(local_appdata) = std::env::var("LOCALAPPDATA") {
let base = format!("{}\\Watchman\\", local_appdata);
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
base
} else {
let base = format!("C:\\Users\\{}\\AppData\\Local\\Watchman\\", cur_user);
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
base
}
}
_ => {
// Fallback to current directory for unknown OS
let _ = fs::create_dir_all(Path::new("./config/"));
"./".to_string()
}
}
}
pub fn get_config_dir() -> String {
#[allow(unused)]
let mut cache_dir = String::new();
let cur_user = whoami::username();
match OS {
"linux" => {
if cur_user.eq_ignore_ascii_case("root") {
let _ = fs::create_dir_all(Path::new("/root/.config/watchman/config/"));
"/root/.config/watchman/".to_string()
} else {
let create_dir = format!("/home/{}/.config/watchman/config/", cur_user);
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
format!("/home/{}/.config/watchman/", cur_user)
}
}
"macos" => {
// /var/root/Library/Application\ Support/com.helloimalemur.watchman/
if cur_user.eq_ignore_ascii_case("root") {
let _ = fs::create_dir_all(Path::new("/var/root/Library/Application\\ Support/com.helloimalemur.watchman/config/"));
"/var/root/Library/Application\\ Support/com.helloimalemur.watchman/".to_string()
} else {
let create_dir = format!("/Users/{}/Library/Application\\ Support/com.helloimalemur.watchman/config/", cur_user);
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
format!("/Users/{}/Library/Application\\ Support/com.helloimalemur.watchman/", cur_user)
}
}
"windows" => {
// Prefer APPDATA (Roaming) for config
if let Ok(appdata) = std::env::var("APPDATA") {
let base = format!("{}\\Watchman\\", appdata);
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
base
} else {
let base = format!("C:\\Users\\{}\\AppData\\Roaming\\Watchman\\", cur_user);
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
base
}
}
_ => {
// Fallback to current directory for unknown OS
let _ = fs::create_dir_all(Path::new("./config/"));
"./".to_string()
}
}
}
pub fn write_default_blacklist<T: ToString>(path: T) {
let _ = fs::write(path.to_string(), default_blacklist());
}
fn default_blacklist() -> &'static str {
r#"
/etc/mtab
/etc/cups
"#
}
pub fn write_default_config<T: ToString>(path: T, webhook: String) {
if fs::write(
path.to_string(),
default_config().replace("https://discord.com/api/webhooks/", webhook.as_str()),
)
.is_ok()
{
println!(
"{}\n ~~~~~~~ UNABLE TO LOCATE CONFIG FILE - DEFAULT CONFIG CREATED ~~~~~~~",
default_config().replace("https://discord.com/api/webhooks/", webhook.as_str())
);
}
}
fn default_config() -> &'static str {
match OS {
"linux" => {
r#"
## General settings
tick_delay_seconds = "5"
fs_tick_delay_seconds = "300"
### File System Integrity
fs_mon_path_variable = true
fs_mon_enabled = "true"
fs_mon_dir = ["/etc", "/bin", "$PATH"]
fs_mon_hash_type = "blake3"
### USB Monitor
usb_mon_enabled = "true"
reboot_on_increase_of_usb_devices = "false"
notify_on_increase_of_usb_devices = "false"
unmount_crypt_on_increase_of_usb_devices = "true"
### Burn File Monitor
burn_file_mon_enabled = "false"
unmount_crypt_on_file_burn = "true"
ssh_check_burn_host = "hostname"
ssh_check_burn_user = "root"
ssh_check_burn_key = "/home/user/.ssh/id_rsa"
ssh_check_burn_path = "/root/.config/burn"
ssh_check_burn_check_interval = "30"
burn_path_1 = "/root/test/"
### Network Monitor
net_mon_enabled = "false"
### Client Discovery & Scan
client_scan_enabled = "false"
client_discovery_method = "arp" # arp | ping
client_scan_exclusions = ["127.0.0.1", "localhost"]
scan_on_add = "true"
nmap_path = "nmap"
nmap_profile = "-sV -T4"
findings_critical_ports = ["23", "2323", "3389", "5900"]
notify_on_findings = "false"
action_on_findings = "none" # none | reboot
######## Notification settings
discord_webhook_url = "https://discord.com/api/webhooks/"
discord_webhook_avatar_name = "Lazarus"
"#
}
"macos" => {
r#"
## General settings
tick_delay_seconds = "5"
fs_tick_delay_seconds = "300"
### File System Integrity
fs_mon_path_variable = true
fs_mon_enabled = "true"
fs_mon_dir = ["/etc", "/bin", "$PATH"]
fs_mon_hash_type = "blake3"
### USB Monitor
usb_mon_enabled = "true"
reboot_on_increase_of_usb_devices = "false"
notify_on_increase_of_usb_devices = "true"
unmount_crypt_on_increase_of_usb_devices = "true"
### Burn File Monitor
burn_file_mon_enabled = "false"
unmount_crypt_on_file_burn = "true"
ssh_check_burn_host = "hostname"
ssh_check_burn_user = "root"
ssh_check_burn_key = "/User/user/.ssh/id_rsa"
ssh_check_burn_path = "/var/root/.config/burn"
ssh_check_burn_check_interval = "30"
burn_path_1 = "/var/root/test/"
### Network Monitor
net_mon_enabled = "false"
### Client Discovery & Scan
client_scan_enabled = "false"
client_discovery_method = "arp" # arp | ping
client_scan_exclusions = ["127.0.0.1", "localhost"]
scan_on_add = "true"
nmap_path = "nmap"
nmap_profile = "-sV -T4"
findings_critical_ports = ["23", "2323", "3389", "5900"]
notify_on_findings = "true"
action_on_findings = "none" # none | reboot
######## Notification settings
discord_webhook_url = "https://discord.com/api/webhooks/"
discord_webhook_avatar_name = "Lazarus"
"#
}
_ => {
panic!("Unsupported OS: {}", OS);
}
}
}

11
watchman.service Normal file
View File

@@ -0,0 +1,11 @@
[Unit]
Description=Watchman
[Service]
Type=simple
User=root
Group=root
ExecStart=watchman
[Install]
WantedBy=multi-user.target