- Add `write_blocklist_files()` to emit final IPv4/IPv6 blocklists to `reports/app/data/` - Add `APP_REPORT_DIR` / `APP_REPORT_DATA_DIR` constants for consolidated outputs - Write firewall state snapshots to `data/mikrotik/` for git diff auditing - Capture router state before scanning; fall back to scan-only if router is unreachable - Use `split_cidr_versions` for CIDR-aware subnet splitting - Update README with output layout and git-diffable state notes
173 lines
6.8 KiB
Python
173 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Consolidated intrusion detection log scanner.
|
|
|
|
Runs both copyparty and Nginx Proxy Manager (NPM) log analyzers,
|
|
and outputs a single consolidated MikroTik RouterOS blocklist script.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
|
|
from .constants import (
|
|
COPYPARTY_LOG_PATH,
|
|
COPYPARTY_REPORT_DIR,
|
|
DEFAULT_ERROR_RATIO,
|
|
DEFAULT_MIN_DENIED,
|
|
DEFAULT_MIN_ERRORS,
|
|
DEFAULT_TOP,
|
|
NPM_LOG_PATH,
|
|
NPM_REPORT_DIR,
|
|
REPORT_DIR,
|
|
SUBNET_THRESHOLD,
|
|
)
|
|
from .firewall import MikrotikFirewall, write_blocklist_files
|
|
from .copyparty import CopypartyAnalyzer
|
|
from .npm import NpmAnalyzer
|
|
from .ip_utils import is_whitelisted
|
|
from .mikrotik import split_cidr_versions
|
|
from .whois import consolidate_by_subnet
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Consolidated IDS Scanner: run copyparty + NPM analyzers and generate unified blocklist."
|
|
)
|
|
|
|
# copyparty arguments
|
|
parser.add_argument("--copyparty-log-path", default=COPYPARTY_LOG_PATH,
|
|
help=f"copyparty log directory (default: {COPYPARTY_LOG_PATH})")
|
|
parser.add_argument("--copyparty-report-dir", default=COPYPARTY_REPORT_DIR,
|
|
help=f"copyparty report directory (default: {COPYPARTY_REPORT_DIR})")
|
|
parser.add_argument("--min-denied", type=int, default=DEFAULT_MIN_DENIED,
|
|
help=f"minimum copyparty denied hits before flagging (default: {DEFAULT_MIN_DENIED})")
|
|
|
|
# NPM arguments
|
|
parser.add_argument("--npm-log-path", default=NPM_LOG_PATH,
|
|
help=f"NPM log directory (default: {NPM_LOG_PATH})")
|
|
parser.add_argument("--npm-report-dir", default=NPM_REPORT_DIR,
|
|
help=f"NPM report directory (default: {NPM_REPORT_DIR})")
|
|
parser.add_argument("--min-errors", type=int, default=DEFAULT_MIN_ERRORS,
|
|
help=f"minimum NPM error responses to flag (default: {DEFAULT_MIN_ERRORS})")
|
|
parser.add_argument("--error-ratio", type=float, default=DEFAULT_ERROR_RATIO,
|
|
help=f"minimum NPM error ratio to flag (default: {DEFAULT_ERROR_RATIO})")
|
|
|
|
# Shared arguments
|
|
parser.add_argument("--report-dir", default=REPORT_DIR,
|
|
help=f"consolidated reports directory (default: {REPORT_DIR})")
|
|
parser.add_argument("--top", type=int, default=DEFAULT_TOP,
|
|
help=f"rows in the top-IP tables (default: {DEFAULT_TOP})")
|
|
parser.add_argument("--quiet", action="store_true",
|
|
help="write files, suppress console reports")
|
|
parser.add_argument("--update-firewall", action="store_true",
|
|
help="synchronize the MikroTik Blocked list after scanning")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="preview firewall changes without applying them")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# --- Step 1-3: connect to MikroTik RouterOS and capture current state ---
|
|
# If the router is unreachable we fall back to scan-only mode: still
|
|
# generate reports and blocklists, but skip the firewall sync.
|
|
firewall: MikrotikFirewall | None = None
|
|
if args.update_firewall:
|
|
firewall = MikrotikFirewall()
|
|
try:
|
|
firewall.connect()
|
|
# Read current firewall rules + address lists and snapshot them to
|
|
# reports/mikrotik/data/ and the git-diffable data/mikrotik/.
|
|
firewall.save_state_report(args.report_dir)
|
|
except Exception as e:
|
|
print(f"\nWARNING: could not read MikroTik router "
|
|
f"({firewall.router_ip}): {e}")
|
|
firewall.disconnect()
|
|
firewall = None
|
|
|
|
# --- Step 4: run copyparty + NPM analyzers -> blocklist candidates ------
|
|
cp_analyzer = CopypartyAnalyzer(
|
|
log_path=args.copyparty_log_path,
|
|
report_dir=args.copyparty_report_dir,
|
|
top=args.top,
|
|
quiet=args.quiet,
|
|
min_denied=args.min_denied,
|
|
)
|
|
|
|
npm_analyzer = NpmAnalyzer(
|
|
log_path=args.npm_log_path,
|
|
report_dir=args.npm_report_dir,
|
|
top=args.top,
|
|
quiet=args.quiet,
|
|
min_errors=args.min_errors,
|
|
error_ratio=args.error_ratio,
|
|
)
|
|
|
|
print("=== Running copyparty scan ===")
|
|
cp_exists = True
|
|
try:
|
|
cp_analyzer.run()
|
|
except Exception as e:
|
|
print(f"Error running copyparty scan: {e}")
|
|
cp_exists = False
|
|
|
|
print("\n=== Running NPM scan ===")
|
|
npm_exists = True
|
|
try:
|
|
npm_analyzer.run()
|
|
except Exception as e:
|
|
print(f"Error running NPM scan: {e}")
|
|
npm_exists = False
|
|
|
|
if not cp_exists and not npm_exists:
|
|
print("\nBoth scans failed. Unable to generate consolidated blocklist.")
|
|
if firewall is not None:
|
|
firewall.disconnect()
|
|
return
|
|
|
|
# Consolidate block candidates
|
|
consolidated_candidates: set[str] = set()
|
|
if cp_exists:
|
|
consolidated_candidates.update(cp_analyzer.block_candidates())
|
|
if npm_exists:
|
|
consolidated_candidates.update(npm_analyzer.block_candidates())
|
|
|
|
# Exclude whitelisted IPs
|
|
final_blocked_ips = {
|
|
ip for ip in consolidated_candidates if not is_whitelisted(ip)}
|
|
|
|
# Group into subnets via WHOIS if multiple malicious IPs in same subnet
|
|
print("\nGrouping blocked IPs into subnets using WHOIS...")
|
|
final_blocked_targets = consolidate_by_subnet(
|
|
final_blocked_ips, threshold=SUBNET_THRESHOLD)
|
|
blocked_ipv4, blocked_ipv6 = split_cidr_versions(final_blocked_targets)
|
|
|
|
print("\n=== Consolidated Summary ===")
|
|
print(
|
|
f"copyparty block candidates: {len(cp_analyzer.block_candidates()) if cp_exists else 0}")
|
|
print(
|
|
f"NPM block candidates: {len(npm_analyzer.block_candidates()) if npm_exists else 0}")
|
|
print(f"Total unified blocked IPs: {len(final_blocked_ips)}")
|
|
print(f"Total target blocks/subnets: {len(final_blocked_targets)}")
|
|
|
|
# --- Step 6: write consolidated blocklists to reports/app/data ----------
|
|
blocklist_v4_path, blocklist_v6_path = write_blocklist_files(
|
|
blocked_ipv4, blocked_ipv6, args.report_dir)
|
|
print(f"blocklist files:\n {blocklist_v4_path}\n {blocklist_v6_path}")
|
|
|
|
# --- Steps 5+7: compare current state with candidates and apply ---------
|
|
if firewall is not None:
|
|
try:
|
|
changes = firewall.sync_blocklist(
|
|
blocked_ipv4, blocked_ipv6, dry_run=args.dry_run,
|
|
report_dir=args.report_dir)
|
|
finally:
|
|
firewall.disconnect()
|
|
action = "Would" if args.dry_run else ""
|
|
print(f"{action} add {len(changes['added_ipv4'])} IPv4 and "
|
|
f"{len(changes['added_ipv6'])} IPv6 addresses; "
|
|
f"remove {len(changes['removed_ipv4'])} IPv4 and "
|
|
f"{len(changes['removed_ipv6'])} IPv6 addresses.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|