first commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
config/Settings.toml
|
||||
Cargo.lock
|
||||
.idea/
|
||||
target/
|
||||
31
Cargo.toml
Normal file
31
Cargo.toml
Normal 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
60
README.md
Normal 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
3
install.sh
Executable 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
113
plan/features.md
Normal file
@@ -0,0 +1,113 @@
|
||||
Here’s 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
15
plan/monetization.md
Normal 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
234
plan/plan.md
Normal 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 2–4 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.
|
||||
21
src/arguments.rs
Normal file
21
src/arguments.rs
Normal 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
59
src/config/linux.rs
Normal 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
85
src/config/macos.rs
Normal 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
21
src/config/mod.rs
Normal 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
193
src/fortiwatch.rs
Normal 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
23
src/main.rs
Normal 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;
|
||||
}
|
||||
82
src/monitors/actions/mod.rs
Normal file
82
src/monitors/actions/mod.rs
Normal 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
296
src/monitors/clients.rs
Normal 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
182
src/monitors/devices.rs
Normal 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
327
src/monitors/filechanges.rs
Normal 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
7
src/monitors/mod.rs
Normal 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
67
src/monitors/network.rs
Normal 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;
|
||||
}
|
||||
87
src/monitors/notify/mod.rs
Normal file
87
src/monitors/notify/mod.rs
Normal 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();
|
||||
// }
|
||||
// }
|
||||
84
src/monitors/ssh_burn_file.rs
Normal file
84
src/monitors/ssh_burn_file.rs
Normal 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
45
src/prometheus/mod.rs
Normal 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
224
src/util/mod.rs
Normal 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
11
watchman.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Watchman
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=watchman
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user