diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2708cab..743c466 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,8 @@ on: pull_request: jobs: - test: + unit-tests: + name: Unit And Contract Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,6 +21,66 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pytest - - name: Run tests + - name: Run unit and contract suite run: pytest -q + + browser-regressions: + name: Browser Regressions + runs-on: ubuntu-latest + env: + NMAPUI_RUN_BROWSER_REGRESSION: "1" + NMAPUI_TRUST_LOCAL_UI: "true" + NMAPUI_ALLOW_UNSAFE_WERKZEUG: "true" + NMAPUI_DEBUG: "false" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Install Playwright Chromium + run: python -m playwright install --with-deps chromium + - name: Run browser regression suite + run: pytest -q tests/test_browser_regressions.py + - name: Upload browser regression diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: browser-regression-diagnostics + path: | + logs + data + .pytest_cache + if-no-files-found: ignore + + packaged-smoke: + name: Packaged App Smoke + runs-on: macos-latest + env: + NMAPUI_RUN_PACKAGED_SMOKE: "1" + NMAPUI_SKIP_OPEN: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run packaged smoke suite + run: pytest -q tests/test_packaged_app_smoke.py + - name: Upload packaged smoke diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: packaged-smoke-diagnostics + path: | + NmapUI.app + logs + data + .pytest_cache + if-no-files-found: ignore diff --git a/README.md b/README.md index 5163e03..1697961 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ A web-based GUI for Nmap network scanning with real-time results, CVE detection, ./build.sh ``` - `build.sh` will automatically run `install.sh` if dependencies haven't been set up yet. After a successful build, look for the network icon in your macOS menu bar. The app serves NmapUI at `http://127.0.0.1:9000`. + `build.sh` will automatically run `install.sh` if dependencies haven't been set up yet. After a successful build, look for the network icon in your macOS menu bar. The app serves NmapUI on a local loopback URL, defaulting to `http://127.0.0.1:9000` and falling back to the next available local port if needed. The wrapper target is selected automatically from your host architecture (`arm64` or `x86_64`). Override it explicitly with `NMAPUI_SWIFT_TARGET` if needed. - The completed `NmapUIMenuBar.app` is installed into `/Applications` when writable, otherwise `~/Applications`, replacing any existing install. Override the destination explicitly with `NMAPUI_APPLICATIONS_DIR`. + The completed `NmapUI.app` is installed into `/Applications` when writable, otherwise `~/Applications`, replacing any existing install. Override the destination explicitly with `NMAPUI_APPLICATIONS_DIR`. > **Prerequisites**: Xcode Command Line Tools (`xcode-select --install`) and [Homebrew](https://brew.sh) are needed by `install.sh` to pull in `nmap`, `arp-scan`, etc. @@ -131,6 +131,10 @@ The app will: Open your browser to `http://127.0.0.1:9000` +### Report Copies on Desktop + +Enable **Settings → Report Exports → Save reports to Desktop** to copy report artifacts (PDF, HTML, XML, Nmap outputs) into `~/Desktop/nmapui-reports`, preserving the scan folder structure. + ### Quick Start (Skip Checks) To skip startup dependency checks: diff --git a/app.py b/app.py index 5d441fb..278f6df 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,7 @@ from nmapui.app_task_bindings import build_task_bindings from nmapui.app_bindings import build_client_state_helpers, build_event_helpers from nmapui.app_composition import ( + build_execute_auto_monitor_rule_deps, build_execute_auto_scan_deps, build_startup_check_deps, ) @@ -128,7 +129,8 @@ logger = logging.getLogger(__name__) app = Flask(__name__) -allowed_origins = get_allowed_origins() +runtime_options = build_runtime_options(sys.argv) +allowed_origins = get_allowed_origins(port=runtime_options["port"]) socketio = SocketIO(app, cors_allowed_origins=allowed_origins) CORS(app, resources={r"/api/*": {"origins": allowed_origins}}) @@ -242,6 +244,7 @@ def safe_emit(event, data=None): runtime_bindings = build_runtime_bindings( build_execute_auto_scan_deps=build_execute_auto_scan_deps, + build_execute_auto_monitor_rule_deps=build_execute_auto_monitor_rule_deps, auto_scan_config=auto_scan_config, get_current_customer=lambda: current_customer, get_last_scan_target=lambda: last_scan_target, @@ -257,6 +260,19 @@ def safe_emit(event, data=None): startup_grace_seconds=AUTO_SCAN_STARTUP_GRACE_SECONDS, current_assignment_loader=state_bindings["load_current_assignment"], set_current_customer=lambda value: globals().__setitem__("current_customer", value), + settings_state=settings_state, + save_settings=lambda payload: save_settings_state( + settings_path=SETTINGS_FILE, + save_json_document=save_json_document, + settings_state=payload, + remote_sync_secret_path=REMOTE_SYNC_SECRET_FILE, + remote_sync_secret_key_path=REMOTE_SYNC_SECRET_KEY_FILE, + ), + job_registry=job_registry, + emit_job_status=emit_job_status, + set_current_customer_state=set_current_customer_state, + set_last_scan_target_state=set_last_scan_target_state, + generate_report_task_provider=lambda: generate_report_task, ) execute_auto_scan = runtime_bindings["execute_auto_scan"] load_current_assignment = runtime_bindings["load_current_assignment"] @@ -315,6 +331,13 @@ def safe_emit(event, data=None): get_app_version=get_app_version, get_default_interface_cached=lambda: DEFAULT_INTERFACE, settings_state=settings_state, + save_settings=lambda payload: save_settings_state( + settings_path=SETTINGS_FILE, + save_json_document=save_json_document, + settings_state=payload, + remote_sync_secret_path=REMOTE_SYNC_SECRET_FILE, + remote_sync_secret_key_path=REMOTE_SYNC_SECRET_KEY_FILE, + ), startup_state=startup_state, runtime_store=runtime_store, get_auto_scan_thread=runtime_bindings["get_auto_scan_thread"], @@ -349,13 +372,6 @@ def safe_emit(event, data=None): start_scan_task=lambda sid, target: start_scan_task(sid, target), generate_report_task=lambda sid, data: generate_report_task(sid, data), generate_pdf_from_saved_task=lambda sid, data: generate_pdf_from_saved_task(sid, data), - save_settings=lambda payload: save_settings_state( - settings_path=SETTINGS_FILE, - save_json_document=save_json_document, - settings_state=payload, - remote_sync_secret_path=REMOTE_SYNC_SECRET_FILE, - remote_sync_secret_key_path=REMOTE_SYNC_SECRET_KEY_FILE, - ), validate_google_drive_settings=lambda *, folder_id: validate_google_drive_settings( folder_id=folder_id, credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, @@ -384,6 +400,9 @@ def safe_emit(event, data=None): key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, requests_module=requests, ), + get_customer_name=lambda customer_id: ( + (customer_fingerprinter.get_customer_by_id(customer_id) or {}).get("name", "") + ), validate_remote_sync_settings=lambda *, endpoint, api_key: validate_remote_sync_settings( endpoint=endpoint, api_key=api_key @@ -473,6 +492,7 @@ def startup_checks(quick=False): def run_server(argv=None): run_server_runtime( argv=argv, + runtime_options=runtime_options if argv is None else build_runtime_options(argv), build_runtime_options=build_runtime_options, log_auth_posture=log_auth_posture, startup_checks=startup_checks, diff --git a/build.sh b/build.sh index 7cead3f..d2935c3 100755 --- a/build.sh +++ b/build.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Build script for NmapUI Menu Bar Wrapper +# Build script for the NmapUI macOS wrapper # Builds the Swift application bundle and opens it # Clean up any existing instances echo "Cleaning up any existing instances..." -pkill -f "NmapUIMenuBar" 2>/dev/null || true +pkill -f "NmapUI.app" 2>/dev/null || true sleep 1 # Give processes time to terminate # Set variables @@ -13,8 +13,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$SCRIPT_DIR" PACKAGING_DIR="$ROOT_DIR/packaging/macos" SRC="$PACKAGING_DIR/NmapUIMenuBarLauncher.swift" -BIN="$ROOT_DIR/NmapUIMenuBar" -APP_NAME="$ROOT_DIR/NmapUIMenuBar.app" +BUILD_DIR="$ROOT_DIR/.build" +BIN="$BUILD_DIR/NmapUI" +APP_NAME="$ROOT_DIR/NmapUI.app" SYSTEM_APPLICATIONS_DIR="/Applications" USER_APPLICATIONS_DIR="$HOME/Applications" BUNDLE_VENV="$APP_NAME/Contents/Resources/.venv" @@ -49,7 +50,7 @@ else APP_INSTALL_DIR="$USER_APPLICATIONS_DIR" fi -INSTALLED_APP_NAME="$APP_INSTALL_DIR/NmapUIMenuBar.app" +INSTALLED_APP_NAME="$APP_INSTALL_DIR/NmapUI.app" INSTALLED_RUNTIME_DB="$INSTALLED_APP_NAME/Contents/Resources/data/runtime.sqlite3" if [[ "${NMAPUI_MIGRATE_DB:-0}" == "1" ]]; then if [[ -n "${NMAPUI_MIGRATE_DB_FROM:-}" ]]; then @@ -64,13 +65,16 @@ if [[ ! -f "$SRC" ]]; then exit 1 fi +# Ensure build output doesn't collide with the nmapui package on case-insensitive filesystems. +mkdir -p "$BUILD_DIR" + # Run install.sh if .venv doesn't exist yet if [[ ! -d "$ROOT_DIR/.venv" && ! -d "$ROOT_DIR/venv" ]]; then echo "No virtual environment found — running install.sh first..." bash "$ROOT_DIR/install.sh" || { echo "install.sh failed"; exit 1; } fi -echo "Building NmapUI Menu Bar Wrapper..." +echo "Building NmapUI macOS wrapper..." echo "Source: $SRC" echo "SDK: $SDK" echo "Host architecture: $HOST_ARCH" @@ -82,6 +86,13 @@ if [[ "${NMAPUI_MIGRATE_DB:-0}" == "1" ]]; then echo "Database migration source: $MIGRATION_SOURCE_DB" fi +# The Swift compiler outputs a binary at $BIN. If a file or directory exists there +# (from a previous build or manual artifact), it will cause compilation to fail. +if [[ -e "$BIN" ]]; then + echo "Removing conflicting build output at $BIN" + rm -rf "$BIN" +fi + # Compile the Swift binary using the requested format swiftc \ -sdk "$SDK" \ @@ -197,11 +208,11 @@ cat > "$APP_NAME/Contents/Info.plist" << EOF CFBundleName - NmapUI Menu Bar + NmapUI CFBundleDisplayName - NmapUI Menu Bar + NmapUI CFBundleIdentifier - com.techmore.nmapuimenubar + com.techmore.nmapui CFBundleVersion $APP_VERSION CFBundleShortVersionString @@ -223,7 +234,7 @@ EOF echo "Application bundle created: $APP_NAME" # Make the binary executable -chmod +x "$APP_NAME/Contents/MacOS/NmapUIMenuBar" +chmod +x "$APP_NAME/Contents/MacOS/NmapUI" # Ad-hoc code sign to satisfy macOS Gatekeeper on locally built bundles. # This prevents "cannot be opened because Apple cannot check it for malicious @@ -269,7 +280,7 @@ else open "$INSTALLED_APP_NAME" fi -echo "Done! The NmapUI Menu Bar application is now running." +echo "Done! The NmapUI application is now running." echo "Look for the network icon in your menu bar." -echo "Use the menu to open http://127.0.0.1:9000 or control the bundled app process." +echo "Use the menu to open the selected local NmapUI URL or control the bundled app process." echo "Menu options: Open NmapUI, Start NmapUI, Stop NmapUI, Quit, Uninstall" diff --git a/nmapui/app_composition.py b/nmapui/app_composition.py index 3d0e770..eb5a2b2 100644 --- a/nmapui/app_composition.py +++ b/nmapui/app_composition.py @@ -35,6 +35,37 @@ def build_execute_auto_scan_deps( } +def build_execute_auto_monitor_rule_deps( + *, + rule, + logger, + network_key, + rate_limiter, + validate_target, + job_registry, + emit_job_status, + generate_report_task, + set_current_customer_state, + set_last_scan_target_state, + settings_state, + save_settings, +): + return { + "rule": rule, + "logger": logger, + "network_key": network_key, + "rate_limiter": rate_limiter, + "validate_target": validate_target, + "job_registry": job_registry, + "emit_job_status": emit_job_status, + "generate_report_task": generate_report_task, + "set_current_customer_state": set_current_customer_state, + "set_last_scan_target_state": set_last_scan_target_state, + "settings_state": settings_state, + "save_settings": save_settings, + } + + def build_scan_task_deps( *, broadcaster, @@ -368,6 +399,7 @@ def build_settings_routes_deps( *, settings_state, save_settings, + get_customer_name, validate_google_drive, validate_remote_sync, get_google_drive_auth_status, @@ -378,6 +410,7 @@ def build_settings_routes_deps( return { "settings_state": settings_state, "save_settings": save_settings, + "get_customer_name": get_customer_name, "validate_google_drive": validate_google_drive, "validate_remote_sync": validate_remote_sync, "get_google_drive_auth_status": get_google_drive_auth_status, diff --git a/nmapui/app_handler_registration.py b/nmapui/app_handler_registration.py index 977ff32..621ee74 100644 --- a/nmapui/app_handler_registration.py +++ b/nmapui/app_handler_registration.py @@ -67,6 +67,7 @@ def register_app_handlers( generate_report_task, generate_pdf_from_saved_task, save_settings, + get_customer_name, validate_google_drive_settings, validate_remote_sync_settings, get_google_drive_auth_status, @@ -181,6 +182,7 @@ def register_app_handlers( settings_routes_deps=build_settings_routes_deps( settings_state=settings_state, save_settings=save_settings, + get_customer_name=get_customer_name, validate_google_drive=validate_google_drive_settings, validate_remote_sync=validate_remote_sync_settings, get_google_drive_auth_status=get_google_drive_auth_status, diff --git a/nmapui/app_runtime.py b/nmapui/app_runtime.py index bb4515b..49bff14 100644 --- a/nmapui/app_runtime.py +++ b/nmapui/app_runtime.py @@ -19,10 +19,12 @@ def start_auto_scan_thread( auto_scan_thread, socketio, auto_scan_config, + settings_state, should_run_auto_scan, startup_at, startup_grace_seconds, execute_auto_scan, + execute_auto_monitor_rule, logger, ): thread_ref = {"thread": auto_scan_thread} @@ -30,10 +32,12 @@ def start_auto_scan_thread( thread_ref=thread_ref, socketio=socketio, auto_scan_config=auto_scan_config, + settings_state=settings_state, should_run_auto_scan=should_run_auto_scan, startup_at=startup_at, startup_grace_seconds=startup_grace_seconds, execute_auto_scan=execute_auto_scan, + execute_auto_monitor_rule=execute_auto_monitor_rule, logger=logger, ) return thread_ref["thread"] @@ -70,6 +74,7 @@ def configure_root_logging(*, base_dir): def run_server( *, argv=None, + runtime_options=None, build_runtime_options, log_auth_posture, startup_checks, @@ -79,8 +84,14 @@ def run_server( app, sys_module, ): - runtime_options = build_runtime_options(argv or sys_module.argv) + runtime_options = runtime_options or build_runtime_options(argv or sys_module.argv) quick_mode = runtime_options["quick_mode"] + if runtime_options.get("port_auto_selected"): + logging.getLogger(__name__).warning( + "Default runtime port %s was unavailable; using %s instead", + runtime_options.get("requested_port"), + runtime_options["port"], + ) log_auth_posture() startup_checks(quick=quick_mode) diff --git a/nmapui/app_runtime_bindings.py b/nmapui/app_runtime_bindings.py index d689649..fd4cca0 100644 --- a/nmapui/app_runtime_bindings.py +++ b/nmapui/app_runtime_bindings.py @@ -8,6 +8,7 @@ execute_auto_scan as execute_auto_scan_runtime, start_auto_scan_thread as start_auto_scan_thread_runtime, ) +from nmapui.auto_scan_runtime import execute_auto_monitor_rule as execute_auto_monitor_rule_runtime from nmapui.app_events_runtime import safe_emit as safe_emit_runtime from nmapui.traceroute_runtime import ( build_traceroute_deps, @@ -119,6 +120,7 @@ def run_traceroute(target="1.1.1.1", sid=None): def build_runtime_bindings( *, build_execute_auto_scan_deps, + build_execute_auto_monitor_rule_deps, auto_scan_config, get_current_customer, get_last_scan_target, @@ -134,6 +136,13 @@ def build_runtime_bindings( startup_grace_seconds, current_assignment_loader, set_current_customer, + settings_state, + save_settings, + job_registry, + emit_job_status, + set_current_customer_state, + set_last_scan_target_state, + generate_report_task_provider, ): def safe_emit(event, data=None): return safe_emit_runtime(event, data) @@ -153,6 +162,24 @@ def execute_auto_scan(): ) ) + def execute_auto_monitor_rule(rule): + return execute_auto_monitor_rule_runtime( + deps=build_execute_auto_monitor_rule_deps( + rule=rule, + logger=logger, + network_key=get_network_key(), + rate_limiter=rate_limiter, + validate_target=validate_target, + job_registry=job_registry, + emit_job_status=emit_job_status, + generate_report_task=generate_report_task_provider(), + set_current_customer_state=set_current_customer_state, + set_last_scan_target_state=set_last_scan_target_state, + settings_state=settings_state, + save_settings=save_settings, + ) + ) + thread_ref = {"thread": auto_scan_thread} def start_auto_scan_thread(): @@ -160,10 +187,12 @@ def start_auto_scan_thread(): auto_scan_thread=thread_ref["thread"], socketio=socketio, auto_scan_config=auto_scan_config, + settings_state=settings_state, should_run_auto_scan=should_run_auto_scan, startup_at=startup_at, startup_grace_seconds=startup_grace_seconds, execute_auto_scan=execute_auto_scan, + execute_auto_monitor_rule=execute_auto_monitor_rule, logger=logger, ) return thread_ref["thread"] @@ -177,6 +206,7 @@ def load_current_assignment(): return { "safe_emit": safe_emit, "execute_auto_scan": execute_auto_scan, + "execute_auto_monitor_rule": execute_auto_monitor_rule, "start_auto_scan_thread": start_auto_scan_thread, "get_auto_scan_thread": get_auto_scan_thread, "load_current_assignment": load_current_assignment, diff --git a/nmapui/app_scan_runtime.py b/nmapui/app_scan_runtime.py index bec617d..2ab9c1b 100644 --- a/nmapui/app_scan_runtime.py +++ b/nmapui/app_scan_runtime.py @@ -79,6 +79,7 @@ def run_nmap_with_xml_output( sid=None, excluded_targets=None, scan_only_mode=False, + force_privileged_scan=False, vulners_script, stylesheet_pdf, emit_to_client, @@ -93,6 +94,7 @@ def run_nmap_with_xml_output( sid=sid, excluded_targets=excluded_targets, scan_only_mode=scan_only_mode, + force_privileged_scan=force_privileged_scan, vulners_script=vulners_script, stylesheet_pdf=stylesheet_pdf, emit_to_client=emit_to_client, diff --git a/nmapui/app_task_bindings.py b/nmapui/app_task_bindings.py index 2e3b5da..28da4d3 100644 --- a/nmapui/app_task_bindings.py +++ b/nmapui/app_task_bindings.py @@ -74,6 +74,7 @@ def run_nmap_with_xml_output( sid=None, excluded_targets=None, scan_only_mode=False, + force_privileged_scan=False, emit_to_client_override=None, ): return run_nmap_with_xml_output_runtime( @@ -83,6 +84,7 @@ def run_nmap_with_xml_output( sid=sid, excluded_targets=excluded_targets, scan_only_mode=scan_only_mode, + force_privileged_scan=force_privileged_scan, vulners_script=vulners_script, stylesheet_pdf=stylesheet_pdf, emit_to_client=emit_to_client_override or emit_to_client, diff --git a/nmapui/auto_monitor.py b/nmapui/auto_monitor.py new file mode 100644 index 0000000..b145d57 --- /dev/null +++ b/nmapui/auto_monitor.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from calendar import monthrange +from datetime import datetime, timedelta +from typing import Any +import uuid + + +AUTO_MONITOR_ALLOWED_RECURRENCES = { + "daily", + "weekly", + "biweekly", + "monthly", + "quarterly", +} +AUTO_MONITOR_ALLOWED_SCAN_MODES = {"complete_pdf"} +WEEKDAY_NAMES = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] +WEEKDAY_TO_INDEX = {name: index for index, name in enumerate(WEEKDAY_NAMES)} +DEFAULT_AUTO_MONITOR_DEFAULTS = { + "enabled_by_default": False, + "recurrence": "weekly", + "day_of_week": "sunday", + "time": "01:00", + "scan_mode": "complete_pdf", +} + + +def _normalize_time(value: Any, *, fallback: str = "01:00") -> str: + text = str(value or fallback).strip() + if len(text) != 5 or text[2] != ":": + return fallback + try: + hour = int(text[:2]) + minute = int(text[3:]) + except ValueError: + return fallback + if not (0 <= hour <= 23 and 0 <= minute <= 59): + return fallback + return f"{hour:02d}:{minute:02d}" + + +def _normalize_day_of_week(value: Any, *, fallback: str = "sunday") -> str: + text = str(value or fallback).strip().lower() + return text if text in WEEKDAY_TO_INDEX else fallback + + +def _normalize_datetime_text(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + try: + return datetime.fromisoformat(text).isoformat() + except ValueError: + return "" + + +def normalize_auto_monitor_defaults(value: Any) -> dict[str, Any]: + value = value if isinstance(value, dict) else {} + recurrence = str( + value.get("recurrence", DEFAULT_AUTO_MONITOR_DEFAULTS["recurrence"]) or "" + ).strip().lower() + if recurrence not in AUTO_MONITOR_ALLOWED_RECURRENCES: + recurrence = DEFAULT_AUTO_MONITOR_DEFAULTS["recurrence"] + scan_mode = str( + value.get("scan_mode", DEFAULT_AUTO_MONITOR_DEFAULTS["scan_mode"]) or "" + ).strip().lower() + if scan_mode not in AUTO_MONITOR_ALLOWED_SCAN_MODES: + scan_mode = DEFAULT_AUTO_MONITOR_DEFAULTS["scan_mode"] + return { + "enabled_by_default": bool( + value.get( + "enabled_by_default", + DEFAULT_AUTO_MONITOR_DEFAULTS["enabled_by_default"], + ) + ), + "recurrence": recurrence, + "day_of_week": _normalize_day_of_week( + value.get("day_of_week", DEFAULT_AUTO_MONITOR_DEFAULTS["day_of_week"]), + fallback=DEFAULT_AUTO_MONITOR_DEFAULTS["day_of_week"], + ), + "time": _normalize_time( + value.get("time", DEFAULT_AUTO_MONITOR_DEFAULTS["time"]), + fallback=DEFAULT_AUTO_MONITOR_DEFAULTS["time"], + ), + "scan_mode": scan_mode, + } + + +def normalize_auto_monitor_rule( + rule: Any, + *, + defaults: dict[str, Any] | None = None, + customer_name_lookup=None, + now: datetime | None = None, +) -> dict[str, Any]: + now = now or datetime.now() + defaults = normalize_auto_monitor_defaults(defaults) + rule = rule if isinstance(rule, dict) else {} + recurrence = str(rule.get("recurrence", defaults["recurrence"]) or "").strip().lower() + if recurrence not in AUTO_MONITOR_ALLOWED_RECURRENCES: + recurrence = defaults["recurrence"] + scan_mode = str(rule.get("scan_mode", defaults["scan_mode"]) or "").strip().lower() + if scan_mode not in AUTO_MONITOR_ALLOWED_SCAN_MODES: + scan_mode = defaults["scan_mode"] + customer_id = str(rule.get("customer_id", "") or "").strip() + customer_name = str(rule.get("customer_name", "") or "").strip() + if not customer_name and callable(customer_name_lookup): + customer_name = str(customer_name_lookup(customer_id) or "").strip() + return { + "id": str(rule.get("id") or uuid.uuid4().hex[:12]), + "customer_id": customer_id, + "customer_name": customer_name, + "enabled": bool(rule.get("enabled", defaults["enabled_by_default"])), + "recurrence": recurrence, + "day_of_week": _normalize_day_of_week( + rule.get("day_of_week", defaults["day_of_week"]), + fallback=defaults["day_of_week"], + ), + "time": _normalize_time( + rule.get("time", defaults["time"]), + fallback=defaults["time"], + ), + "scan_mode": scan_mode, + "target": str(rule.get("target", "") or "").strip(), + "public_ip": str(rule.get("public_ip", "") or "").strip(), + "last_run": _normalize_datetime_text(rule.get("last_run")), + "anchor_date": _normalize_datetime_text( + rule.get("anchor_date") or rule.get("created_at") or now.isoformat() + ), + "created_at": _normalize_datetime_text(rule.get("created_at") or now.isoformat()), + "updated_at": _normalize_datetime_text(rule.get("updated_at") or now.isoformat()), + } + + +def normalize_auto_monitor_settings( + value: Any, + *, + customer_name_lookup=None, +) -> dict[str, Any]: + value = value if isinstance(value, dict) else {} + defaults = normalize_auto_monitor_defaults(value.get("defaults")) + rules = [] + seen = set() + for entry in value.get("rules") or []: + normalized = normalize_auto_monitor_rule( + entry, + defaults=defaults, + customer_name_lookup=customer_name_lookup, + ) + if not normalized["customer_id"] or normalized["id"] in seen: + continue + seen.add(normalized["id"]) + rules.append(normalized) + return {"defaults": defaults, "rules": rules} + + +def build_default_auto_monitor_rule( + *, + customer_id: str, + customer_name: str, + public_ip: str = "", + target: str = "", + defaults: dict[str, Any] | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + now = now or datetime.now() + defaults = normalize_auto_monitor_defaults(defaults) + return normalize_auto_monitor_rule( + { + "customer_id": customer_id, + "customer_name": customer_name, + "enabled": defaults["enabled_by_default"], + "recurrence": defaults["recurrence"], + "day_of_week": defaults["day_of_week"], + "time": defaults["time"], + "scan_mode": defaults["scan_mode"], + "target": target, + "public_ip": public_ip, + "anchor_date": now.isoformat(), + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + }, + defaults=defaults, + now=now, + ) + + +def _parse_anchor(rule: dict[str, Any], *, now: datetime) -> datetime: + anchor = _normalize_datetime_text(rule.get("anchor_date") or rule.get("created_at")) + return datetime.fromisoformat(anchor) if anchor else now + + +def _next_daily_run(rule: dict[str, Any], *, now: datetime) -> datetime: + hour, minute = map(int, rule["time"].split(":")) + candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + return candidate if candidate > now else candidate + timedelta(days=1) + + +def _next_weekly_run(rule: dict[str, Any], *, now: datetime, interval_weeks: int) -> datetime: + target_weekday = WEEKDAY_TO_INDEX.get(rule["day_of_week"], 6) + hour, minute = map(int, rule["time"].split(":")) + candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + candidate += timedelta(days=(target_weekday - candidate.weekday()) % 7) + if candidate <= now: + candidate += timedelta(days=7) + if interval_weeks <= 1: + return candidate + + anchor = _parse_anchor(rule, now=now) + anchor_week_start = (anchor - timedelta(days=anchor.weekday())).date() + while True: + candidate_week_start = (candidate - timedelta(days=candidate.weekday())).date() + weeks_between = (candidate_week_start - anchor_week_start).days // 7 + if weeks_between >= 0 and weeks_between % interval_weeks == 0: + return candidate + candidate += timedelta(days=7) + + +def _shift_months(base: datetime, months: int) -> datetime: + year = base.year + ((base.month - 1 + months) // 12) + month = ((base.month - 1 + months) % 12) + 1 + day = min(base.day, monthrange(year, month)[1]) + return base.replace(year=year, month=month, day=day) + + +def _next_monthly_run(rule: dict[str, Any], *, now: datetime, interval_months: int) -> datetime: + anchor = _parse_anchor(rule, now=now) + hour, minute = map(int, rule["time"].split(":")) + candidate = anchor.replace(hour=hour, minute=minute, second=0, microsecond=0) + while candidate <= now: + candidate = _shift_months(candidate, interval_months) + return candidate + + +def get_next_auto_monitor_run( + rule: dict[str, Any], *, now: datetime | None = None +) -> datetime | None: + now = now or datetime.now() + if not isinstance(rule, dict) or not rule.get("enabled"): + return None + recurrence = str(rule.get("recurrence") or "").strip().lower() + if recurrence == "daily": + return _next_daily_run(rule, now=now) + if recurrence == "weekly": + return _next_weekly_run(rule, now=now, interval_weeks=1) + if recurrence == "biweekly": + return _next_weekly_run(rule, now=now, interval_weeks=2) + if recurrence == "monthly": + return _next_monthly_run(rule, now=now, interval_months=1) + if recurrence == "quarterly": + return _next_monthly_run(rule, now=now, interval_months=3) + return None + + +def build_auto_monitor_rule_status( + rule: dict[str, Any], *, now: datetime | None = None +) -> dict[str, Any]: + now = now or datetime.now() + next_run = get_next_auto_monitor_run(rule, now=now) + payload = dict(rule) + payload["next_run"] = next_run.isoformat() if next_run else None + payload["seconds_until_next_run"] = ( + max(int((next_run - now).total_seconds()), 0) if next_run else None + ) + return payload + + +def get_due_auto_monitor_rules( + auto_monitor_settings: dict[str, Any], + *, + now: datetime, + startup_at: datetime, + startup_grace_seconds: int, +) -> list[dict[str, Any]]: + if (now - startup_at).total_seconds() < startup_grace_seconds: + return [] + + due = [] + for rule in (auto_monitor_settings or {}).get("rules") or []: + if not rule.get("enabled"): + continue + next_run = get_next_auto_monitor_run(rule, now=now - timedelta(minutes=1)) + if next_run is not None and next_run <= now: + due.append(rule) + return due diff --git a/nmapui/auto_scan_runtime.py b/nmapui/auto_scan_runtime.py index 9e01a33..50ff4ba 100644 --- a/nmapui/auto_scan_runtime.py +++ b/nmapui/auto_scan_runtime.py @@ -1,5 +1,6 @@ from datetime import datetime +from nmapui.auto_monitor import normalize_auto_monitor_settings AUTO_SCAN_SID = "__auto_scan__" @@ -53,3 +54,94 @@ def execute_auto_scan(*, deps): except Exception as exc: logger.error("Auto scan failed: %s", exc) safe_emit("auto_scan_error", {"error": str(exc)}) + + +def execute_auto_monitor_rule(*, deps): + rule = deps["rule"] + logger = deps["logger"] + network_key = deps["network_key"] + rate_limiter = deps["rate_limiter"] + validate_target = deps["validate_target"] + job_registry = deps["job_registry"] + emit_job_status = deps["emit_job_status"] + generate_report_task = deps["generate_report_task"] + set_current_customer_state = deps["set_current_customer_state"] + set_last_scan_target_state = deps["set_last_scan_target_state"] + settings_state = deps["settings_state"] + save_settings = deps["save_settings"] + + target = str( + rule.get("target") or rule.get("public_ip") or network_key.get("cidr") or "" + ).strip() + if not target: + logger.warning("Auto-monitor rule %s has no target", rule.get("id")) + return + + is_valid, error_msg = validate_target(target) + if not is_valid: + logger.warning( + "Auto-monitor target invalid for rule %s: %s", + rule.get("id"), + error_msg, + ) + return + + can_scan, rate_msg = rate_limiter.can_scan(AUTO_SCAN_SID) + if not can_scan: + logger.warning( + "Auto-monitor rate limited for rule %s: %s", + rule.get("id"), + rate_msg, + ) + return + + if not job_registry.start( + AUTO_SCAN_SID, + "report", + { + "target": target, + "customer_name": rule.get("customer_name"), + "chunked": False, + "auto_monitor_rule_id": rule.get("id"), + }, + ): + logger.info( + "Skipping auto-monitor rule %s because a report job is already running", + rule.get("id"), + ) + return + + rate_limiter.record_scan(AUTO_SCAN_SID) + set_current_customer_state( + { + "id": rule.get("customer_id", "unknown"), + "name": rule.get("customer_name", "Unknown"), + "confidence": 1.0, + "metadata": {"auto_monitor": True, "rule_id": rule.get("id")}, + }, + sid=AUTO_SCAN_SID, + ) + set_last_scan_target_state(target, sid=AUTO_SCAN_SID) + emit_job_status(AUTO_SCAN_SID, "report") + generate_report_task( + AUTO_SCAN_SID, + { + "target": target, + "customer_name": rule.get("customer_name", "Unknown"), + "chunked": False, + "auto_scan": True, + "auto_monitor": True, + "auto_monitor_rule_id": rule.get("id"), + }, + ) + + auto_monitor = normalize_auto_monitor_settings( + (settings_state or {}).get("auto_monitor", {}) + ) + for entry in auto_monitor.get("rules", []): + if entry.get("id") == rule.get("id"): + entry["last_run"] = datetime.now().isoformat() + entry["updated_at"] = entry["last_run"] + break + settings_state["auto_monitor"] = auto_monitor + save_settings(settings_state) diff --git a/nmapui/bootstrap.py b/nmapui/bootstrap.py index 54aa345..a9efa67 100644 --- a/nmapui/bootstrap.py +++ b/nmapui/bootstrap.py @@ -1,5 +1,6 @@ from datetime import datetime import os +import socket from flask import Flask from flask_cors import CORS @@ -8,23 +9,67 @@ from .runtime import env_flag -def get_allowed_origins(): +DEFAULT_RUNTIME_PORT = 9000 +RUNTIME_PORT_SEARCH_LIMIT = 20 + + +def get_allowed_origins(*, port=None): """Return the explicit CORS allowlist for HTTP and Socket.IO.""" configured = os.environ.get("NMAPUI_ALLOWED_ORIGINS", "").strip() if configured: return [origin.strip() for origin in configured.split(",") if origin.strip()] + selected_port = int(port or os.environ.get("NMAPUI_PORT", str(DEFAULT_RUNTIME_PORT))) return [ - "http://127.0.0.1:9000", - "http://localhost:9000", + f"http://127.0.0.1:{selected_port}", + f"http://localhost:{selected_port}", ] -def build_runtime_options(argv): +def _is_port_available(host, port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((host, port)) + except OSError: + return False + return True + + +def select_runtime_port(host, requested_port, *, explicit=False): + """Pick a runtime port, honoring explicit requests and otherwise falling back.""" + if explicit: + if not _is_port_available(host, requested_port): + raise RuntimeError( + f"Requested NmapUI port {requested_port} is already in use on {host}" + ) + return requested_port + + for candidate in range(requested_port, requested_port + RUNTIME_PORT_SEARCH_LIMIT): + if _is_port_available(host, candidate): + return candidate + + raise RuntimeError( + "Unable to find an available local runtime port " + f"between {requested_port} and {requested_port + RUNTIME_PORT_SEARCH_LIMIT - 1}" + ) + + +def build_runtime_options(argv, *, selected_port=None): """Build server runtime options from argv and environment.""" + host = os.environ.get("NMAPUI_HOST", "127.0.0.1") + requested_port = int(os.environ.get("NMAPUI_PORT", str(DEFAULT_RUNTIME_PORT))) + explicit_port = bool(os.environ.get("NMAPUI_PORT", "").strip()) + resolved_port = ( + int(selected_port) + if selected_port is not None + else select_runtime_port(host, requested_port, explicit=explicit_port) + ) return { "quick_mode": "--quick" in argv or "-q" in argv, - "host": os.environ.get("NMAPUI_HOST", "127.0.0.1"), - "port": int(os.environ.get("NMAPUI_PORT", "9000")), + "host": host, + "port": resolved_port, + "requested_port": requested_port, + "port_auto_selected": resolved_port != requested_port, "debug": env_flag("NMAPUI_DEBUG", default=False), "allow_unsafe_werkzeug": env_flag( "NMAPUI_ALLOW_UNSAFE_WERKZEUG", default=False @@ -32,9 +77,9 @@ def build_runtime_options(argv): } -def create_web_app(import_name): +def create_web_app(import_name, *, port=None): """Create the Flask app and Socket.IO server with the default CORS policy.""" - allowed_origins = get_allowed_origins() + allowed_origins = get_allowed_origins(port=port) app = Flask(import_name) socketio = SocketIO(app, cors_allowed_origins=allowed_origins) CORS(app, resources={r"/api/*": {"origins": allowed_origins}}) diff --git a/nmapui/handlers/auto_scan.py b/nmapui/handlers/auto_scan.py index 042c241..62d846e 100644 --- a/nmapui/handlers/auto_scan.py +++ b/nmapui/handlers/auto_scan.py @@ -4,6 +4,7 @@ from flask import jsonify, request from flask_socketio import emit +from nmapui.auto_monitor import get_due_auto_monitor_rules, normalize_auto_monitor_settings from nmapui.auto_scan import ( build_auto_scan_status_payload, validate_auto_scan_config_update as default_validate_auto_scan_config_update, @@ -58,7 +59,7 @@ def update_auto_scan(): return jsonify({"success": True}) -def auto_scan_loop(*, socketio, auto_scan_config, should_run_auto_scan, startup_at, startup_grace_seconds, execute_auto_scan, logger): +def auto_scan_loop(*, socketio, auto_scan_config, settings_state=None, should_run_auto_scan, startup_at, startup_grace_seconds, execute_auto_scan, execute_auto_monitor_rule=None, logger): """Background loop to check and execute auto scans.""" last_check_minute = None @@ -83,6 +84,23 @@ def auto_scan_loop(*, socketio, auto_scan_config, should_run_auto_scan, startup_ logger.info("Executing auto scan") execute_auto_scan() + + auto_monitor_settings = normalize_auto_monitor_settings( + (settings_state or {}).get("auto_monitor", {}) + ) + for rule in get_due_auto_monitor_rules( + auto_monitor_settings, + now=now, + startup_at=startup_at, + startup_grace_seconds=startup_grace_seconds, + ): + logger.info( + "Executing auto-monitor rule %s for customer %s", + rule.get("id"), + rule.get("customer_name"), + ) + if execute_auto_monitor_rule is not None: + execute_auto_monitor_rule(rule) except Exception as exc: logger.error("Auto scan loop error: %s", exc) @@ -110,7 +128,7 @@ def acquire_auto_scan_scheduler_lock(*, lock_file=AUTO_SCAN_SCHEDULER_LOCK_FILE) return handle -def start_auto_scan_thread(*, thread_ref, socketio, auto_scan_config, should_run_auto_scan, startup_at, startup_grace_seconds, execute_auto_scan, logger, acquire_scheduler_lock=acquire_auto_scan_scheduler_lock): +def start_auto_scan_thread(*, thread_ref, socketio, auto_scan_config, settings_state=None, should_run_auto_scan, startup_at, startup_grace_seconds, execute_auto_scan, execute_auto_monitor_rule=None, logger, acquire_scheduler_lock=acquire_auto_scan_scheduler_lock): """Start the auto-scan worker once per process.""" if thread_ref["thread"] and thread_ref["thread"].is_alive(): return @@ -128,10 +146,12 @@ def start_auto_scan_thread(*, thread_ref, socketio, auto_scan_config, should_run kwargs={ "socketio": socketio, "auto_scan_config": auto_scan_config, + "settings_state": settings_state, "should_run_auto_scan": should_run_auto_scan, "startup_at": startup_at, "startup_grace_seconds": startup_grace_seconds, "execute_auto_scan": execute_auto_scan, + "execute_auto_monitor_rule": execute_auto_monitor_rule, "logger": logger, }, daemon=True, diff --git a/nmapui/handlers/routes.py b/nmapui/handlers/routes.py index 83dcc96..7555263 100644 --- a/nmapui/handlers/routes.py +++ b/nmapui/handlers/routes.py @@ -112,6 +112,7 @@ def runtime_status(): @app.route("/api/runtime/settings-summary") def runtime_settings_summary(): scan_rules = settings_state.get("scan_rules", {}) + reports = settings_state.get("reports", {}) sync = settings_state.get("sync", {}) maintenance_backfill = {} maintenance_retention = {} @@ -139,6 +140,7 @@ def runtime_settings_summary(): "scan_only_mode": bool(scan_rules.get("scan_only_mode", False)), "excluded_targets_count": len(scan_rules.get("excluded_targets", [])), "target_profiles_count": len(settings_state.get("target_profiles", [])), + "reports_save_to_desktop": bool(reports.get("save_to_desktop", False)), "google_drive_enabled": bool( (sync.get("google_drive") or {}).get("enabled", False) ), diff --git a/nmapui/handlers/settings.py b/nmapui/handlers/settings.py index ee88333..028ca3b 100644 --- a/nmapui/handlers/settings.py +++ b/nmapui/handlers/settings.py @@ -1,5 +1,6 @@ from flask import jsonify, request +from nmapui.auto_monitor import build_auto_monitor_rule_status from nmapui.auth import require_auth from nmapui.settings import normalize_settings_document @@ -9,6 +10,7 @@ def register_settings_routes(app, deps): save_settings = deps["save_settings"] validate_google_drive = deps["validate_google_drive"] validate_remote_sync = deps["validate_remote_sync"] + get_customer_name = deps.get("get_customer_name") get_google_drive_auth_status = deps["get_google_drive_auth_status"] build_google_drive_auth_url = deps["build_google_drive_auth_url"] exchange_google_drive_auth_code = deps["exchange_google_drive_auth_code"] @@ -17,7 +19,12 @@ def register_settings_routes(app, deps): @app.route("/api/settings") @require_auth def get_settings(): - return jsonify(normalize_settings_document(settings_state)) + return jsonify( + normalize_settings_document( + settings_state, + customer_name_lookup=get_customer_name, + ) + ) @app.route("/api/settings", methods=["POST"]) @require_auth @@ -31,6 +38,24 @@ def update_settings(): settings_state.update(normalized) return jsonify({"success": True, "settings": normalized}) + @app.route("/api/settings/auto-monitor") + @require_auth + def get_auto_monitor_settings(): + normalized = normalize_settings_document( + settings_state, + customer_name_lookup=get_customer_name, + ) + auto_monitor = normalized.get("auto_monitor", {}) + return jsonify( + { + "defaults": auto_monitor.get("defaults", {}), + "rules": [ + build_auto_monitor_rule_status(rule) + for rule in auto_monitor.get("rules", []) + ], + } + ) + @app.route("/api/settings/validate/google-drive", methods=["POST"]) @require_auth def validate_google_drive_settings_route(): diff --git a/nmapui/scanning.py b/nmapui/scanning.py index 8e150ab..7106710 100644 --- a/nmapui/scanning.py +++ b/nmapui/scanning.py @@ -258,6 +258,7 @@ def run_nmap_with_xml_output( sid=None, excluded_targets=None, scan_only_mode=False, + force_privileged_scan=False, vulners_script, stylesheet_pdf, emit_to_client, @@ -266,7 +267,10 @@ def run_nmap_with_xml_output( run_cancellable_command, ): """Run nmap with all formats output (-oA).""" - scan_technique = get_nmap_scan_technique(force_unprivileged=scan_only_mode) + if force_privileged_scan: + scan_technique = "-sS" + else: + scan_technique = get_nmap_scan_technique(force_unprivileged=scan_only_mode) excluded_targets = [ str(item or "").strip() for item in (excluded_targets or []) if str(item or "").strip() ] @@ -301,6 +305,7 @@ def run_nmap_with_xml_output( cmd = [ "nmap", scan_technique, + "-Pn", "-T4", "-A", "-sC", @@ -341,6 +346,28 @@ def run_nmap_with_xml_output( result = run_cancellable_command( cmd, sid=sid, job_type="report" if sid else None, timeout=timeout_seconds ) + if force_privileged_scan and result.returncode != 0 and _is_permission_denied(result): + logger.warning("Privileged scan denied; retrying with unprivileged connect scan") + if sid: + emit_to_client( + sid, + "scan_feedback", + "Privileged scan denied; retrying with unprivileged connect scan", + ) + else: + socketio_emit( + "scan_feedback", + "Privileged scan denied; retrying with unprivileged connect scan", + ) + socketio_sleep(0) + fallback_cmd = cmd[:] + fallback_cmd[1] = "-sT" + result = run_cancellable_command( + fallback_cmd, + sid=sid, + job_type="report" if sid else None, + timeout=timeout_seconds, + ) end_time = datetime.now() duration = (end_time - start_time).total_seconds() diff --git a/nmapui/settings.py b/nmapui/settings.py index 5cdf88e..9c3a01a 100644 --- a/nmapui/settings.py +++ b/nmapui/settings.py @@ -8,6 +8,7 @@ from cryptography.fernet import Fernet, InvalidToken +from .auto_monitor import normalize_auto_monitor_settings SETTINGS_SCHEMA_VERSION = 1 ENCRYPTED_REMOTE_SYNC_SCHEMA_VERSION = 1 @@ -18,6 +19,9 @@ "scan_only_mode": False, "excluded_targets": [], }, + "reports": { + "save_to_desktop": False, + }, "sync": { "google_drive": { "enabled": False, @@ -31,6 +35,16 @@ "status": "Not configured", }, }, + "auto_monitor": { + "defaults": { + "enabled_by_default": False, + "recurrence": "weekly", + "day_of_week": "sunday", + "time": "01:00", + "scan_mode": "complete_pdf", + }, + "rules": [], + }, } @@ -148,9 +162,11 @@ def normalize_settings_document( document: Any, *, remote_sync_api_key_configured: bool | None = None, + customer_name_lookup=None, ) -> dict[str, Any]: document = document if isinstance(document, dict) else {} scan_rules = document.get("scan_rules") + reports = document.get("reports") sync = document.get("sync") google_drive = sync.get("google_drive") if isinstance(sync, dict) else {} remote_sync = sync.get("remote_sync") if isinstance(sync, dict) else {} @@ -174,6 +190,9 @@ def normalize_settings_document( (scan_rules or {}).get("excluded_targets", []) ), }, + "reports": { + "save_to_desktop": bool((reports or {}).get("save_to_desktop", False)), + }, "sync": { "google_drive": { "enabled": bool((google_drive or {}).get("enabled", False)), @@ -196,6 +215,10 @@ def normalize_settings_document( ).strip(), }, }, + "auto_monitor": normalize_auto_monitor_settings( + document.get("auto_monitor", {}), + customer_name_lookup=customer_name_lookup, + ), } diff --git a/nmapui/workflows.py b/nmapui/workflows.py index 4e8e5ff..3f3635d 100644 --- a/nmapui/workflows.py +++ b/nmapui/workflows.py @@ -1,6 +1,8 @@ from datetime import datetime import logging +from pathlib import Path import re +import shutil from nmapui.runtime_log import append_runtime_log from nmapui.reporting import ( @@ -14,6 +16,44 @@ logger = logging.getLogger(__name__) +def copy_report_files_to_desktop( + *, + files, + scan_dir, + scans_dir, + emit_to_client, + sid, + socketio_sleep, +): + if scan_dir is None or scans_dir is None: + return + + try: + desktop_root = Path.home() / "Desktop" / "nmapui-reports" + relative_path = scan_dir.relative_to(scans_dir) + destination_dir = desktop_root / relative_path + destination_dir.mkdir(parents=True, exist_ok=True) + + copied = 0 + for path in files.values(): + if not path or not path.exists(): + continue + shutil.copy2(path, destination_dir / path.name) + copied += 1 + + if copied: + emit_to_client( + sid, + "scan_feedback", + f"📌 Copied {copied} report files to {destination_dir}", + ) + socketio_sleep(0) + except Exception as exc: + logger.warning("Failed to copy report files to Desktop: %s", exc) + emit_to_client(sid, "scan_feedback", "⚠️ Unable to copy report files to Desktop") + socketio_sleep(0) + + def start_deep_scan(context, targets, sid, is_gateway_phase=False): emit_to_client = context.emit_to_client socketio_sleep = context.socketio_sleep @@ -463,6 +503,7 @@ def generate_report_task(context, sid, data): run_kwargs["excluded_targets"] = excluded_targets if scan_only_mode: run_kwargs["scan_only_mode"] = True + run_kwargs["force_privileged_scan"] = True if not run_nmap_with_xml_output( chunk_target, @@ -579,6 +620,15 @@ def generate_report_task(context, sid, data): "nmap": scan_dir / "scan.nmap", "gnmap": scan_dir / "scan.gnmap", } + if (context.settings_state or {}).get("reports", {}).get("save_to_desktop"): + copy_report_files_to_desktop( + files=files, + scan_dir=scan_dir, + scans_dir=scans_dir, + emit_to_client=emit_to_client, + sid=sid, + socketio_sleep=socketio_sleep, + ) end_time = datetime.now() duration = end_time - start_time diff --git a/packaging/macos/NmapUIMenuBarLauncher.swift b/packaging/macos/NmapUIMenuBarLauncher.swift index 7a96ec5..f0723d3 100644 --- a/packaging/macos/NmapUIMenuBarLauncher.swift +++ b/packaging/macos/NmapUIMenuBarLauncher.swift @@ -1,13 +1,23 @@ import Cocoa +import Darwin class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { - let appURL = URL(string: "http://127.0.0.1:9000")! - let runtimeStatusURL = URL(string: "http://127.0.0.1:9000/api/runtime/status")! + var runtimePort = 9000 var statusItem: NSStatusItem! var pythonProcess: Process? var statusPollTimer: Timer? var hadActiveJob = false var completedIndicatorUntil: Date? + var lockFileURL: URL? + var launchedWithPrivileges = false + + var appURL: URL { + URL(string: "http://127.0.0.1:\(runtimePort)")! + } + + var runtimeStatusURL: URL { + URL(string: "http://127.0.0.1:\(runtimePort)/api/runtime/status")! + } // Menu items that need dynamic state updates var openItem: NSMenuItem! @@ -16,10 +26,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var restartItem: NSMenuItem! var isFlaskRunning: Bool { - pythonProcess?.isRunning == true + if launchedWithPrivileges { + return isLocalPortInUse(runtimePort) + } + return pythonProcess?.isRunning == true } func applicationDidFinishLaunching(_ notification: Notification) { + if !acquireSingleInstanceLock() { + NSApp.terminate(nil) + return + } setupStatusItem() startFlask() } @@ -73,6 +90,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // MARK: - Flask lifecycle + func isLocalPortInUse(_ port: Int) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return true } + defer { close(fd) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = UInt16(port).bigEndian + let convertResult = withUnsafeMutablePointer(to: &address.sin_addr) { + inet_pton(AF_INET, "127.0.0.1", $0) + } + guard convertResult == 1 else { return true } + + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.stride)) + } + } + return result == 0 + } + + func pickAvailableRuntimePort(startingAt startPort: Int = 9000, attempts: Int = 20) -> Int? { + for candidate in startPort..<(startPort + attempts) { + if !isLocalPortInUse(candidate) { + return candidate + } + } + return nil + } + func startFlask() { guard !isFlaskRunning else { return } @@ -85,15 +133,36 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return } + guard let selectedPort = pickAvailableRuntimePort() else { + updateStatusIcon(state: .error, detail: "No local runtime port available") + return + } + runtimePort = selectedPort + + let allowedOrigins = "http://127.0.0.1:\(runtimePort),http://localhost:\(runtimePort)" + startStatusPolling() + updateStatusIcon(state: .starting, detail: "Starting NmapUI") + + if startFlaskWithPrivileges(runScriptPath: runScriptPath, allowedOrigins: allowedOrigins) { + launchedWithPrivileges = true + pythonProcess = nil + print("NmapUI started with elevated privileges") + return + } + let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = [runScriptPath] process.currentDirectoryURL = URL(fileURLWithPath: resourcesPath, isDirectory: true) + var environment = ProcessInfo.processInfo.environment + environment["NMAPUI_PORT"] = String(runtimePort) + environment["NMAPUI_ALLOWED_ORIGINS"] = allowedOrigins + process.environment = environment do { try process.run() pythonProcess = process - startStatusPolling() + launchedWithPrivileges = false updateStatusIcon(state: .starting, detail: "Starting NmapUI") print("NmapUI started with PID: \(process.processIdentifier)") } catch { @@ -106,11 +175,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // previous run or if pythonProcess is stale after a crash/restart). func stopFlask(wait: Bool = true) { stopStatusPolling() - if let process = pythonProcess, process.isRunning { + if launchedWithPrivileges { + stopFlaskWithPrivileges() + launchedWithPrivileges = false + pythonProcess = nil + } else if let process = pythonProcess, process.isRunning { process.terminate() if wait { process.waitUntilExit() } + pythonProcess = nil } - pythonProcess = nil // Belt-and-suspenders: kill any app.py that may still be alive let pkill = Process() @@ -123,7 +196,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { updateStatusIcon(state: .idle, detail: "NmapUI stopped") } - // Poll localhost:9000 until Flask responds (or timeout), then open browser + // Poll the selected localhost port until Flask responds (or timeout), then open browser func waitForFlaskThenOpen(timeout: TimeInterval = 15) { DispatchQueue.global(qos: .userInitiated).async { let deadline = Date().addingTimeInterval(timeout) @@ -179,7 +252,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc func uninstallApp(_ sender: Any?) { let alert = NSAlert() alert.messageText = "Uninstall NmapUI" - alert.informativeText = "Quit the app and move NmapUIMenuBar.app to the Trash to uninstall." + alert.informativeText = "Quit the app and move NmapUI.app to the Trash to uninstall." alert.addButton(withTitle: "OK") alert.alertStyle = .informational alert.runModal() @@ -187,6 +260,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationWillTerminate(_ notification: Notification) { stopFlask(wait: false) + releaseSingleInstanceLock() statusItem = nil } @@ -324,6 +398,92 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibility) button.toolTip = detail } + + // MARK: - Privileged start/stop + + func startFlaskWithPrivileges(runScriptPath: String, allowedOrigins: String) -> Bool { + let command = "NMAPUI_PORT=\(runtimePort) NMAPUI_ALLOWED_ORIGINS=\(allowedOrigins) \"\(runScriptPath)\" >/tmp/nmapui-privileged.log 2>&1 &" + let appleScript = "do shell script \(appleScriptEscaped(command)) with administrator privileges" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", appleScript] + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + print("Failed to start privileged NmapUI: \(error)") + return false + } + } + + func stopFlaskWithPrivileges() { + let command = "/usr/bin/pkill -f \"/Applications/NmapUI.app/Contents/Resources/app.py\"" + let appleScript = "do shell script \(appleScriptEscaped(command)) with administrator privileges" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", appleScript] + do { + try process.run() + process.waitUntilExit() + } catch { + print("Failed to stop privileged NmapUI: \(error)") + } + } + + func appleScriptEscaped(_ value: String) -> String { + var escaped = value.replacingOccurrences(of: "\\", with: "\\\\") + escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + // MARK: - Single instance lock + + func acquireSingleInstanceLock() -> Bool { + let fileManager = FileManager.default + let lockDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("NmapUI", isDirectory: true) + guard let lockDirectory else { return true } + + do { + try fileManager.createDirectory(at: lockDirectory, withIntermediateDirectories: true) + } catch { + print("Unable to create lock directory: \(error)") + return true + } + + let lockFile = lockDirectory.appendingPathComponent("nmapui.lock") + lockFileURL = lockFile + + if let contents = try? String(contentsOf: lockFile).trimmingCharacters(in: .whitespacesAndNewlines), + let pid = Int32(contents), + pid > 0, + pid != getpid(), + kill(pid, 0) == 0 { + let alert = NSAlert() + alert.messageText = "NmapUI already running" + alert.informativeText = "Another instance of NmapUI is already running. Use the existing menu bar icon." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + return false + } + + do { + try "\(getpid())".write(to: lockFile, atomically: true, encoding: .utf8) + } catch { + print("Unable to write lock file: \(error)") + } + + return true + } + + func releaseSingleInstanceLock() { + guard let lockFileURL else { return } + try? FileManager.default.removeItem(at: lockFileURL) + } } func main() { diff --git a/packaging/macos/README.md b/packaging/macos/README.md index 6576609..d6c0b23 100644 --- a/packaging/macos/README.md +++ b/packaging/macos/README.md @@ -5,7 +5,7 @@ A lightweight macOS menu bar application that launches the bundled NmapUI server ## Features - Network icon in the menu bar -- Opens NmapUI at `http://127.0.0.1:9000` +- Opens NmapUI on a local loopback URL, defaulting to `http://127.0.0.1:9000` - Starts and stops the bundled Python app process - Runs as a menu bar app with no dock icon - Uses a single supported Swift entrypoint: `NmapUIMenuBarLauncher.swift` @@ -39,9 +39,9 @@ The app appears as a network icon in the menu bar. Use the menu to start, stop, 1. Creates a menu bar item with a network icon. 2. Starts the bundled `run.sh` helper from the app bundle resources. -3. Opens the browser at `http://127.0.0.1:9000`. +3. Opens the browser at the selected local runtime URL. 4. Terminates the child process on quit. ## Support Contract -Only `NmapUIMenuBarLauncher.swift` is supported. Older popover and relay prototypes have been removed from the tracked build path so the wrapper release flow and port contract stay deterministic. +Only `NmapUIMenuBarLauncher.swift` is supported. Older popover and relay prototypes have been removed from the tracked build path so the wrapper release flow and port contract stay deterministic. The built bundle is installed as `NmapUI.app`. diff --git a/packaging/macos/SETUP.md b/packaging/macos/SETUP.md index 6ff0dc9..8e33963 100644 --- a/packaging/macos/SETUP.md +++ b/packaging/macos/SETUP.md @@ -4,7 +4,7 @@ This guide documents the supported macOS wrapper implementation for launching th ## Supported Wrapper Source -Use [NmapUIMenuBarLauncher.swift](/Users/seandolbec/Projects/NmapUI/packaging/macos/NmapUIMenuBarLauncher.swift) as the only supported source file. It launches the bundled `run.sh` helper and opens `http://127.0.0.1:9000`. +Use [NmapUIMenuBarLauncher.swift](/Users/techmore/projects/NmapUI/packaging/macos/NmapUIMenuBarLauncher.swift) as the only supported source file. It launches the bundled `run.sh` helper and opens the selected local runtime URL, defaulting to `http://127.0.0.1:9000`. ## Build and Run @@ -21,7 +21,7 @@ Use [NmapUIMenuBarLauncher.swift](/Users/seandolbec/Projects/NmapUI/packaging/ma ## Development Contract -- The wrapper opens `http://127.0.0.1:9000` +- The wrapper opens the selected local runtime URL and defaults to `http://127.0.0.1:9000` - The wrapper source of truth is `NmapUIMenuBarLauncher.swift` - Older `9999` popover examples are deprecated and removed from the supported path @@ -29,7 +29,7 @@ Use [NmapUIMenuBarLauncher.swift](/Users/seandolbec/Projects/NmapUI/packaging/ma ### Changing the Menu Bar Icon -Modify this line in [NmapUIMenuBarLauncher.swift](/Users/seandolbec/Projects/NmapUI/packaging/macos/NmapUIMenuBarLauncher.swift): +Modify this line in [NmapUIMenuBarLauncher.swift](/Users/techmore/projects/NmapUI/packaging/macos/NmapUIMenuBarLauncher.swift): ```swift button.image = NSImage(systemSymbolName: "network", accessibilityDescription: "NmapUI") @@ -39,10 +39,12 @@ Replace `"network"` with any SF Symbol name. ### Changing the Target URL -Modify this line in [NmapUIMenuBarLauncher.swift](/Users/seandolbec/Projects/NmapUI/packaging/macos/NmapUIMenuBarLauncher.swift): +Modify the `appURL` computed property in [NmapUIMenuBarLauncher.swift](/Users/techmore/projects/NmapUI/packaging/macos/NmapUIMenuBarLauncher.swift): ```swift -let appURL = URL(string: "http://127.0.0.1:9000")! +var appURL: URL { + URL(string: "http://127.0.0.1:\\(runtimePort)")! +} ``` ## Removed Prototype diff --git a/static/js/customer_ui.js b/static/js/customer_ui.js index 7e901a9..801b263 100644 --- a/static/js/customer_ui.js +++ b/static/js/customer_ui.js @@ -1,6 +1,121 @@ let customersTabLoaded = false; let customerFormMode = 'add'; let editingCustomerId = null; +let customerAutoMonitorState = { defaults: null, rules: [] }; + +function getAutoMonitorRule(customer) { + const customerId = String(customer?.id || ''); + return (customerAutoMonitorState.rules || []).find( + (rule) => String(rule.customer_id || '') === customerId + ) || null; +} + +function getDefaultAutoMonitorRule(customer) { + const defaults = customerAutoMonitorState.defaults || { + enabled_by_default: false, + recurrence: 'weekly', + day_of_week: 'sunday', + time: '01:00', + scan_mode: 'complete_pdf', + }; + return { + enabled: defaults.enabled_by_default, + recurrence: defaults.recurrence, + day_of_week: defaults.day_of_week, + time: defaults.time, + target: customer?.networks?.public_ip && customer.networks.public_ip !== 'dynamic' + ? customer.networks.public_ip + : '', + next_run: null, + }; +} + +async function loadAutoMonitorSettings() { + const response = await fetch('/api/settings/auto-monitor', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`Auto-monitor settings request failed with HTTP ${response.status}`); + } + customerAutoMonitorState = await response.json(); + return customerAutoMonitorState; +} + +async function saveAutoMonitorRule(customer, updates) { + const settingsResponse = await fetch('/api/settings', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!settingsResponse.ok) { + throw new Error(`Settings request failed with HTTP ${settingsResponse.status}`); + } + + const currentSettings = await settingsResponse.json(); + const nextSettings = structuredClone(currentSettings || {}); + const defaults = nextSettings.auto_monitor?.defaults + || customerAutoMonitorState.defaults + || {}; + const rules = Array.isArray(nextSettings.auto_monitor?.rules) + ? nextSettings.auto_monitor.rules.slice() + : []; + const customerId = String(customer.id || ''); + const existingIndex = rules.findIndex( + (rule) => String(rule.customer_id || '') === customerId + ); + const baseRule = existingIndex >= 0 + ? rules[existingIndex] + : { + customer_id: customerId, + customer_name: customer.name || '', + enabled: Boolean(defaults.enabled_by_default), + recurrence: defaults.recurrence || 'weekly', + day_of_week: defaults.day_of_week || 'sunday', + time: defaults.time || '01:00', + scan_mode: 'complete_pdf', + target: customer.networks?.public_ip && customer.networks.public_ip !== 'dynamic' + ? customer.networks.public_ip + : '', + public_ip: customer.networks?.public_ip || '', + }; + + const nextRule = { + ...baseRule, + ...updates, + customer_id: customerId, + customer_name: customer.name || '', + public_ip: customer.networks?.public_ip || baseRule.public_ip || '', + scan_mode: 'complete_pdf', + }; + + if (!nextSettings.auto_monitor) { + nextSettings.auto_monitor = { defaults, rules: [] }; + } + if (existingIndex >= 0) { + rules[existingIndex] = nextRule; + } else { + rules.push(nextRule); + } + nextSettings.auto_monitor.defaults = defaults; + nextSettings.auto_monitor.rules = rules; + + const saveResponse = await fetch('/api/settings', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(nextSettings), + }); + if (!saveResponse.ok) { + throw new Error(`Settings save failed with HTTP ${saveResponse.status}`); + } + + const saved = await saveResponse.json(); + customerAutoMonitorState = saved.settings.auto_monitor || customerAutoMonitorState; + return customerAutoMonitorState; +} function showCustomerForm(mode = 'add') { customerFormMode = mode; @@ -275,6 +390,58 @@ function renderCustomersTab(customers) { `; card.appendChild(details); + const autoMonitorRule = getAutoMonitorRule(customer) || getDefaultAutoMonitorRule(customer); + const autoMonitor = document.createElement('section'); + autoMonitor.className = 'mt-4 rounded-2xl border border-olive-200 bg-white p-4'; + autoMonitor.innerHTML = ` +
+
+

Auto-monitor

+

Run Complete + PDF automatically for this customer.

+
+ +
+
+ + + + +
+
+ ${autoMonitorRule.next_run ? `Next run ${new Date(autoMonitorRule.next_run).toLocaleString()}` : 'No scheduled run yet'} +
+ `; + card.appendChild(autoMonitor); + const actions = document.createElement('div'); actions.className = 'mt-4 flex flex-wrap gap-2'; @@ -310,6 +477,31 @@ function renderCustomersTab(customers) { }); actions.appendChild(deleteButton); + const saveMonitorButton = document.createElement('button'); + saveMonitorButton.type = 'button'; + saveMonitorButton.className = 'action-button action-button-primary action-button-compact'; + saveMonitorButton.textContent = 'Save Auto-monitor'; + saveMonitorButton.addEventListener('click', async () => { + const updates = { + enabled: autoMonitor.querySelector('.customer-auto-monitor-enabled')?.checked || false, + recurrence: autoMonitor.querySelector('.customer-auto-monitor-recurrence')?.value || 'weekly', + day_of_week: autoMonitor.querySelector('.customer-auto-monitor-day')?.value || 'sunday', + time: autoMonitor.querySelector('.customer-auto-monitor-time')?.value || '01:00', + target: autoMonitor.querySelector('.customer-auto-monitor-target')?.value || '', + }; + + try { + setCustomerTabStatus(`Saving auto-monitor rule for ${customer.name}...`); + await saveAutoMonitorRule(customer, updates); + await loadAutoMonitorSettings(); + renderCustomersTab(customers); + setCustomerTabStatus(`Saved auto-monitor rule for ${customer.name}.`); + } catch (error) { + setCustomerTabStatus(`Failed to save auto-monitor rule: ${error.message}`, true); + } + }); + actions.appendChild(saveMonitorButton); + card.appendChild(actions); list.appendChild(card); }); @@ -320,9 +512,16 @@ function loadCustomersTab(force = false) { return; } setCustomerTabStatus('Loading customers...'); - if (window.socket) { - window.socket.emit('get_customers'); - } + Promise.resolve() + .then(() => loadAutoMonitorSettings()) + .catch(() => { + customerAutoMonitorState = { defaults: null, rules: [] }; + }) + .finally(() => { + if (window.socket) { + window.socket.emit('get_customers'); + } + }); } function initializeCustomerUI(socket) { diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 19c816c..8ef6e2a 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -34,6 +34,9 @@ function getSettingsFormState() { .map((value) => value.trim()) .filter(Boolean), }, + reports: { + save_to_desktop: document.getElementById('settings-save-reports-desktop')?.checked || false, + }, sync: { google_drive: { enabled: document.getElementById('settings-google-drive-enabled')?.checked || false, @@ -47,6 +50,16 @@ function getSettingsFormState() { status: document.getElementById('settings-remote-sync-status')?.textContent || 'Not configured', }, }, + auto_monitor: { + defaults: { + enabled_by_default: document.getElementById('settings-auto-monitor-enabled-by-default')?.checked || false, + recurrence: document.getElementById('settings-auto-monitor-recurrence')?.value || 'weekly', + day_of_week: document.getElementById('settings-auto-monitor-day')?.value || 'sunday', + time: document.getElementById('settings-auto-monitor-time')?.value || '01:00', + scan_mode: 'complete_pdf', + }, + rules: settingsState?.auto_monitor?.rules || [], + }, }; } @@ -95,6 +108,7 @@ function renderRuntimeSummary(summary) { ['Profiles', String(summary.target_profiles_count || 0)], ['Exclusions', String(summary.excluded_targets_count || 0)], ['Scan-only mode', summary.scan_only_mode ? 'Enabled' : 'Disabled'], + ['Desktop reports', summary.reports_save_to_desktop ? 'Enabled' : 'Disabled'], ['Google Drive', summary.google_drive_enabled ? 'Enabled' : 'Disabled'], ['Remote sync', summary.remote_sync_enabled ? 'Enabled' : 'Disabled'], ['Reports in DB', String(persistedCounts.report_artifacts || 0)], @@ -258,6 +272,7 @@ function fillSettingsForm(state) { settingsState = state; document.getElementById('settings-scan-only-mode').checked = !!state.scan_rules?.scan_only_mode; document.getElementById('settings-excluded-targets').value = (state.scan_rules?.excluded_targets || []).join('\n'); + document.getElementById('settings-save-reports-desktop').checked = !!state.reports?.save_to_desktop; document.getElementById('settings-profile-scan-only-mode').checked = false; document.getElementById('settings-profile-excluded-targets').value = ''; document.getElementById('settings-google-drive-enabled').checked = !!state.sync?.google_drive?.enabled; @@ -267,6 +282,10 @@ function fillSettingsForm(state) { document.getElementById('settings-remote-sync-endpoint').value = state.sync?.remote_sync?.endpoint || ''; document.getElementById('settings-remote-sync-api-key').value = state.sync?.remote_sync?.api_key || ''; document.getElementById('settings-remote-sync-status').textContent = state.sync?.remote_sync?.status || 'Not configured'; + document.getElementById('settings-auto-monitor-enabled-by-default').checked = !!state.auto_monitor?.defaults?.enabled_by_default; + document.getElementById('settings-auto-monitor-recurrence').value = state.auto_monitor?.defaults?.recurrence || 'weekly'; + document.getElementById('settings-auto-monitor-day').value = state.auto_monitor?.defaults?.day_of_week || 'sunday'; + document.getElementById('settings-auto-monitor-time').value = state.auto_monitor?.defaults?.time || '01:00'; populateProfileCustomerOptions(); renderTargetProfiles(state.target_profiles || []); } @@ -544,6 +563,16 @@ function addTargetProfile() { target_profiles: [], scan_rules: { scan_only_mode: false, excluded_targets: [] }, sync: { google_drive: {}, remote_sync: {} }, + auto_monitor: { + defaults: { + enabled_by_default: false, + recurrence: 'weekly', + day_of_week: 'sunday', + time: '01:00', + scan_mode: 'complete_pdf', + }, + rules: [], + }, }; settingsState.target_profiles = settingsState.target_profiles || []; diff --git a/templates/index.html b/templates/index.html index 8e67101..605fc32 100644 --- a/templates/index.html +++ b/templates/index.html @@ -788,6 +788,63 @@

Scan Rules

+ +
+

Report Exports

+

Keep a copy of each report outside the app bundle for easy access.

+
+ +
+
+ +
+

Auto-monitor Defaults

+

Define the default schedule used when you enable auto-monitoring for a customer.

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ New customer rules default to Complete + PDF. Existing customer-specific rules can still override the inherited schedule from the Customers tab. +
+
+
diff --git a/tests/test_auto_monitor_modules.py b/tests/test_auto_monitor_modules.py new file mode 100644 index 0000000..58e4261 --- /dev/null +++ b/tests/test_auto_monitor_modules.py @@ -0,0 +1,81 @@ +from datetime import datetime + +from nmapui.auto_monitor import ( + build_auto_monitor_rule_status, + build_default_auto_monitor_rule, + get_due_auto_monitor_rules, + get_next_auto_monitor_run, + normalize_auto_monitor_settings, +) + + +def test_normalize_auto_monitor_settings_applies_defaults_and_filters_invalid_rules(): + settings = normalize_auto_monitor_settings( + { + "defaults": {"recurrence": "weekly", "day_of_week": "sunday", "time": "01:00"}, + "rules": [ + {"customer_id": "cust-1", "customer_name": "Acme", "enabled": True}, + {"customer_id": "", "customer_name": "Missing"}, + ], + } + ) + + assert settings["defaults"]["recurrence"] == "weekly" + assert len(settings["rules"]) == 1 + assert settings["rules"][0]["day_of_week"] == "sunday" + assert settings["rules"][0]["time"] == "01:00" + + +def test_get_next_auto_monitor_run_supports_weekly_and_biweekly(): + weekly_rule = build_default_auto_monitor_rule( + customer_id="cust-1", + customer_name="Acme", + defaults={"recurrence": "weekly", "day_of_week": "sunday", "time": "01:00"}, + now=datetime(2026, 3, 15, 0, 0), + ) + weekly_rule["enabled"] = True + + biweekly_rule = dict(weekly_rule) + biweekly_rule["recurrence"] = "biweekly" + biweekly_rule["anchor_date"] = "2026-03-15T00:00:00" + + assert get_next_auto_monitor_run( + weekly_rule, now=datetime(2026, 3, 15, 0, 30) + ).isoformat() == "2026-03-15T01:00:00" + assert get_next_auto_monitor_run( + biweekly_rule, now=datetime(2026, 3, 16, 0, 0) + ).isoformat() == "2026-03-29T01:00:00" + + +def test_get_due_auto_monitor_rules_returns_enabled_rule_when_next_run_has_arrived(): + rule = build_default_auto_monitor_rule( + customer_id="cust-1", + customer_name="Acme", + defaults={"recurrence": "daily", "time": "01:00"}, + now=datetime(2026, 3, 14, 0, 0), + ) + rule["enabled"] = True + + due = get_due_auto_monitor_rules( + {"rules": [rule]}, + now=datetime(2026, 3, 14, 1, 0), + startup_at=datetime(2026, 3, 14, 0, 0), + startup_grace_seconds=0, + ) + + assert due == [rule] + + +def test_build_auto_monitor_rule_status_includes_next_run(): + rule = build_default_auto_monitor_rule( + customer_id="cust-1", + customer_name="Acme", + defaults={"recurrence": "daily", "time": "01:00"}, + now=datetime(2026, 3, 14, 0, 0), + ) + rule["enabled"] = True + + status = build_auto_monitor_rule_status(rule, now=datetime(2026, 3, 14, 0, 30)) + + assert status["next_run"] == "2026-03-14T01:00:00" + assert status["seconds_until_next_run"] == 1800 diff --git a/tests/test_bootstrap_modules.py b/tests/test_bootstrap_modules.py index a48ed08..abd526e 100644 --- a/tests/test_bootstrap_modules.py +++ b/tests/test_bootstrap_modules.py @@ -1,10 +1,12 @@ from nmapui.bootstrap import ( + DEFAULT_RUNTIME_PORT, begin_startup_state, build_runtime_options, create_web_app, complete_startup_state, get_allowed_origins, run_socketio_server, + select_runtime_port, ) @@ -20,6 +22,8 @@ def test_build_runtime_options_uses_env_and_argv(monkeypatch): "quick_mode": True, "host": "0.0.0.0", "port": 9100, + "requested_port": 9100, + "port_auto_selected": False, "debug": True, "allow_unsafe_werkzeug": True, } @@ -29,8 +33,8 @@ def test_get_allowed_origins_defaults_to_local_ui_hosts(monkeypatch): monkeypatch.delenv("NMAPUI_ALLOWED_ORIGINS", raising=False) assert get_allowed_origins() == [ - "http://127.0.0.1:9000", - "http://localhost:9000", + f"http://127.0.0.1:{DEFAULT_RUNTIME_PORT}", + f"http://localhost:{DEFAULT_RUNTIME_PORT}", ] @@ -46,6 +50,36 @@ def test_get_allowed_origins_uses_environment_allowlist(monkeypatch): ] +def test_get_allowed_origins_uses_selected_port_when_not_explicitly_configured(monkeypatch): + monkeypatch.delenv("NMAPUI_ALLOWED_ORIGINS", raising=False) + monkeypatch.delenv("NMAPUI_PORT", raising=False) + + assert get_allowed_origins(port=9101) == [ + "http://127.0.0.1:9101", + "http://localhost:9101", + ] + + +def test_select_runtime_port_falls_back_when_default_port_is_busy(monkeypatch): + monkeypatch.setattr( + "nmapui.bootstrap._is_port_available", + lambda host, port: port == 9001, + ) + + assert select_runtime_port("127.0.0.1", 9000, explicit=False) == 9001 + + +def test_select_runtime_port_rejects_busy_explicit_port(monkeypatch): + monkeypatch.setattr("nmapui.bootstrap._is_port_available", lambda host, port: False) + + try: + select_runtime_port("127.0.0.1", 9100, explicit=True) + except RuntimeError as exc: + assert "9100" in str(exc) + else: # pragma: no cover - defensive failure path only + raise AssertionError("Expected explicit busy port selection to fail") + + def test_begin_startup_state_resets_transient_fields(): state = { "startup_complete": True, @@ -107,9 +141,9 @@ def cors_stub(app, resources): bootstrap.Flask = FlaskStub bootstrap.SocketIO = SocketIOStub bootstrap.CORS = cors_stub - bootstrap.get_allowed_origins = lambda: ["https://scanner.example.com"] + bootstrap.get_allowed_origins = lambda *, port=None: ["https://scanner.example.com"] try: - app, socketio = create_web_app("nmapui.app") + app, socketio = create_web_app("nmapui.app", port=9102) finally: bootstrap.Flask = original_flask bootstrap.SocketIO = original_socketio diff --git a/tests/test_packaged_app_smoke.py b/tests/test_packaged_app_smoke.py index 8074479..d09686f 100644 --- a/tests/test_packaged_app_smoke.py +++ b/tests/test_packaged_app_smoke.py @@ -11,7 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] -APP_BUNDLE = ROOT / "NmapUIMenuBar.app" +APP_BUNDLE = ROOT / "NmapUI.app" RUN_SCRIPT = APP_BUNDLE / "Contents" / "Resources" / "run.sh" diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 3842846..692c9e2 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -120,6 +120,38 @@ def test_template_uses_shared_customer_ui_module(): assert "window.initializeCustomerUI = initializeCustomerUI;" in customer_source assert "window.addCustomer = () => {" in customer_source assert "window.assignCustomer = () => {" in customer_source + assert "loadAutoMonitorSettings()" in customer_source + assert "saveAutoMonitorRule(customer, updates)" in customer_source + assert "customer-auto-monitor-recurrence" in customer_source + assert "Save Auto-monitor" in customer_source + + +def test_settings_tab_includes_auto_monitor_defaults(): + html = (ROOT / "templates" / "index.html").read_text() + settings_source = (ROOT / "static" / "js" / "settings_tab.js").read_text() + + assert "Auto-monitor Defaults" in html + assert 'id="settings-auto-monitor-recurrence"' in html + assert 'id="settings-auto-monitor-day"' in html + assert 'id="settings-auto-monitor-time"' in html + assert 'id="settings-auto-monitor-enabled-by-default"' in html + assert "auto_monitor: {" in settings_source + assert "settings-auto-monitor-recurrence" in settings_source + assert "settings-auto-monitor-time" in settings_source + + +def test_ci_workflow_covers_browser_and_packaged_smoke_jobs(): + ci_source = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + + assert "unit-tests:" in ci_source + assert "browser-regressions:" in ci_source + assert "packaged-smoke:" in ci_source + assert 'NMAPUI_RUN_BROWSER_REGRESSION: "1"' in ci_source + assert 'NMAPUI_RUN_PACKAGED_SMOKE: "1"' in ci_source + assert "python -m playwright install --with-deps chromium" in ci_source + assert "pytest -q tests/test_browser_regressions.py" in ci_source + assert "pytest -q tests/test_packaged_app_smoke.py" in ci_source + assert "actions/upload-artifact@v4" in ci_source def test_template_uses_shared_report_status_module(): @@ -176,7 +208,9 @@ def test_wrapper_contract_uses_single_supported_launcher(): assert 'elif [[ -d "$SYSTEM_APPLICATIONS_DIR" && -w "$SYSTEM_APPLICATIONS_DIR" ]]; then' in build_script assert 'APP_INSTALL_DIR="$SYSTEM_APPLICATIONS_DIR"' in build_script assert 'APP_INSTALL_DIR="$USER_APPLICATIONS_DIR"' in build_script - assert 'INSTALLED_APP_NAME="$APP_INSTALL_DIR/NmapUIMenuBar.app"' in build_script + assert 'BIN="$ROOT_DIR/NmapUI"' in build_script + assert 'APP_NAME="$ROOT_DIR/NmapUI.app"' in build_script + assert 'INSTALLED_APP_NAME="$APP_INSTALL_DIR/NmapUI.app"' in build_script assert 'INSTALLED_RUNTIME_DB="$INSTALLED_APP_NAME/Contents/Resources/data/runtime.sqlite3"' in build_script assert 'if [[ "${NMAPUI_MIGRATE_DB:-0}" == "1" ]]; then' in build_script assert 'if [[ -n "${NMAPUI_MIGRATE_DB_FROM:-}" ]]; then' in build_script @@ -184,6 +218,8 @@ def test_wrapper_contract_uses_single_supported_launcher(): assert 'MIGRATION_SOURCE_DB="$INSTALLED_RUNTIME_DB"' in build_script assert 'echo "Host architecture: $HOST_ARCH"' in build_script assert "$APP_VERSION" in build_script + assert "NmapUI" in build_script + assert "com.techmore.nmapui" in build_script assert 'echo "Target: $SWIFT_TARGET"' in build_script assert 'echo "Install destination: $INSTALLED_APP_NAME"' in build_script assert 'echo "Database migration enabled"' in build_script @@ -198,11 +234,13 @@ def test_wrapper_contract_uses_single_supported_launcher(): assert 'if [[ "${NMAPUI_SKIP_OPEN:-}" == "1" ]]; then' in build_script assert 'echo "Skipping application auto-open because NMAPUI_SKIP_OPEN=1"' in build_script assert 'open "$INSTALLED_APP_NAME"' in build_script + assert 'pkill -f "NmapUI.app" 2>/dev/null || true' in build_script assert "export NMAPUI_ALLOW_UNSAFE_WERKZEUG=true" in build_script assert "export NMAPUI_TRUST_LOCAL_UI=true" in build_script assert 'BUNDLE_PLAYWRIGHT_BROWSERS="$APP_NAME/Contents/Resources/playwright-browsers"' in build_script assert 'PLAYWRIGHT_BROWSERS_PATH="$BUNDLE_PLAYWRIGHT_BROWSERS" python -m playwright install chromium' in build_script assert 'export PLAYWRIGHT_BROWSERS_PATH="$(pwd)/playwright-browsers"' in build_script + assert 'chmod +x "$APP_NAME/Contents/MacOS/NmapUI"' in build_script assert "VULNERS_RUNTIME_FILES=(" in build_script assert "nmap-vulners/vulners.nse" in build_script assert "nmap-vulners/http-vulners-regex.nse" in build_script @@ -216,6 +254,7 @@ def test_wrapper_docs_reference_current_local_port(): for doc_name in ("README.md", "packaging/macos/README.md", "packaging/macos/SETUP.md"): source = (ROOT / doc_name).read_text() assert "127.0.0.1:9000" in source + assert "selected local runtime URL" in source or "local loopback URL" in source assert "localhost:9999" not in source readme = (ROOT / "README.md").read_text() assert "NMAPUI_SWIFT_TARGET" in readme @@ -747,7 +786,14 @@ def test_runtime_status_route_and_menu_bar_indicator_contract(): assert '"has_active_jobs": bool(active_jobs)' in jobs_source assert '"job_registry": job_registry' in composition_source assert "job_registry=job_registry," in app_source - assert 'let runtimeStatusURL = URL(string: "http://127.0.0.1:9000/api/runtime/status")!' in launcher_source + assert "var runtimePort = 9000" in launcher_source + assert 'var appURL: URL {' in launcher_source + assert 'URL(string: "http://127.0.0.1:\\(runtimePort)")!' in launcher_source + assert 'var runtimeStatusURL: URL {' in launcher_source + assert 'URL(string: "http://127.0.0.1:\\(runtimePort)/api/runtime/status")!' in launcher_source + assert 'environment["NMAPUI_PORT"] = String(runtimePort)' in launcher_source + assert 'environment["NMAPUI_ALLOWED_ORIGINS"] = "http://127.0.0.1:\\(runtimePort),http://localhost:\\(runtimePort)"' in launcher_source + assert "pickAvailableRuntimePort" in launcher_source assert "startStatusPolling()" in launcher_source assert "pollRuntimeStatus()" in launcher_source assert "Recent scan or report completed" in launcher_source @@ -803,13 +849,10 @@ def test_app_startup_checks_quick_mode_executes_successfully(): def test_app_runtime_uses_bootstrap_origin_and_server_policy(): - app_source = subprocess.check_output( - ["git", "show", ":app.py"], - cwd=ROOT, - text=True, - ) + app_source = (ROOT / "app.py").read_text() - assert 'allowed_origins = get_allowed_origins()' in app_source + assert 'runtime_options = build_runtime_options(sys.argv)' in app_source + assert 'allowed_origins = get_allowed_origins(port=runtime_options["port"])' in app_source assert 'SocketIO(app, cors_allowed_origins=allowed_origins)' in app_source assert 'CORS(app, resources={r"/api/*": {"origins": allowed_origins}})' in app_source assert "run_server_runtime(" in app_source @@ -940,7 +983,8 @@ def test_app_delegates_startup_checks_to_shared_module(): assert "thread_ref[\"thread\"] = start_auto_scan_thread_runtime(" in app_runtime_bindings_source assert "def configure_root_logging(*, base_dir):" in app_runtime_source assert "def run_server(" in app_runtime_source - assert "runtime_options = build_runtime_options(argv or sys_module.argv)" in app_runtime_source + assert "runtime_options = runtime_options or build_runtime_options(argv or sys_module.argv)" in app_runtime_source + assert 'if runtime_options.get("port_auto_selected"):' in app_runtime_source assert "run_socketio_server(socketio, app, runtime_options)" in app_runtime_source assert "run_startup_checks(deps, quick=quick)" in app_runtime_source assert "handler_start_auto_scan_thread(" in app_runtime_source