Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ permissions:

jobs:
test:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -28,7 +28,7 @@ jobs:

build:
needs: test
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ These community projects provide additional NetSapiens security capabilities:

- **[rekeyandsync](https://github.com/kselkowitz/rekeyandsync)** — Rekey and resync SIP device credentials

- **[ua-monitor](https://github.com/traviscw/ua-monitor)** — SIP Registration Monitor for VoIPMonitor

## Roadmap

- [x] ModSecurity installation and configuration with OWASP CRS
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ version = "0.1.0"
description = "Open-source NetSapiens security platform - audit tools and hardening automation"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9"
requires-python = ">=3.8"
authors = [
{ name = "Sumner Robinson" }
]
Expand All @@ -20,6 +20,7 @@ classifiers = [
"License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand Down Expand Up @@ -63,11 +64,11 @@ include = [

[tool.black]
line-length = 100
target-version = ["py39"]
target-version = ["py38"]

[tool.ruff]
line-length = 100
target-version = "py39"
target-version = "py38"

[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
Expand All @@ -76,7 +77,7 @@ select = ["E", "F", "I", "N", "W", "UP"]
"src/nssec/modules/waf/config.py" = ["E501"]

[tool.mypy]
python_version = "3.9"
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true

Expand Down
2 changes: 2 additions & 0 deletions src/nssec/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Shared utilities used by CLI sub-modules (audit, waf_commands, etc.).
"""

from __future__ import annotations

from pathlib import Path
from typing import Optional

Expand Down
32 changes: 2 additions & 30 deletions src/nssec/cli/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,22 +132,6 @@ def _display_audit_summary(failed, warnings, passed, skipped, results):
)


def _connect_remote_host(host):
"""Set up SSH connection to a remote host for auditing."""
from nssec.core.cache import session_cache
from nssec.core.ssh import SSHExecutor, set_remote_host

console.print(f"[bold]Connecting to {host}...[/bold]")
executor = SSHExecutor(host)
success, message = executor.test_connection()
if not success:
console.print(f"[red]SSH connection failed: {message}[/red]")
raise SystemExit(1)
console.print(f"[green]{message}[/green]\n")
set_remote_host(host)
session_cache.clear()


def _filter_checks(applicable, category, checks, skip):
"""Apply category, include, and exclude filters to check list."""
if category:
Expand All @@ -161,11 +145,6 @@ def _filter_checks(applicable, category, checks, skip):


@audit.command("run")
@click.option(
"--host",
"-H",
help="Remote host to audit via SSH (e.g., user@hostname)",
)
@click.option(
"--checks",
"-c",
Expand All @@ -184,17 +163,10 @@ def _filter_checks(applicable, category, checks, skip):
type=click.Choice(["apiban", "firewall", "ssh", "mysql", "netsapiens"]),
help="Run only checks in category",
)
def audit_run(host, checks, skip, verbose, category):
"""Run a full security audit.

Use --host to audit a remote server via SSH:
nssec audit run --host ubuntu@myserver.example.com
"""
def audit_run(checks, skip, verbose, category):
"""Run a full security audit."""
from nssec.core.checks import get_checks_for_server_type

if host:
_connect_remote_host(host)

server_type = detect_server_type()
console.print(f"[bold]Security Audit - {server_type.value.upper()} Server[/bold]\n")

Expand Down
74 changes: 53 additions & 21 deletions src/nssec/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,66 @@
)


def _setup_remote_host(host: str, use_sudo: bool = False) -> None:
"""Set up SSH connection to a remote host."""
from nssec.core.cache import session_cache
from nssec.core.ssh import SSHExecutor, set_remote_host, set_use_sudo

console.print(f"[bold]Connecting to {host}...[/bold]")
executor = SSHExecutor(host)
success, message = executor.test_connection()
if not success:
console.print(f"[red]SSH connection failed: {message}[/red]")
raise SystemExit(1)
console.print(f"[green]{message}[/green]")
set_remote_host(host)
set_use_sudo(use_sudo)
if use_sudo:
console.print("[dim]Sudo enabled - you may be prompted for password[/dim]")
console.print()
session_cache.clear()


@click.group()
@click.version_option(version=__version__, prog_name="nssec")
@click.option(
"--host",
"-H",
envvar="NSSEC_HOST",
help="Remote host to connect via SSH (e.g., user@hostname). Can also set NSSEC_HOST env var.",
)
@click.option(
"--sudo",
"-S",
is_flag=True,
envvar="NSSEC_SUDO",
help="Run commands with sudo (for privileged operations like waf init).",
)
@click.pass_context
def cli(ctx):
def cli(ctx, host, sudo):
"""NS-Security: Open-source NetSapiens security platform.

Audit tools and hardening automation for NetSapiens clusters.

Use --host to run commands on a remote NetSapiens server via SSH:

nssec --host ubuntu@myserver.example.com audit run

Use --sudo for commands that require root privileges:

nssec --host ubuntu@myserver.example.com --sudo waf init
"""
ctx.ensure_object(dict)
ctx.obj["host"] = host
ctx.obj["sudo"] = sudo

# Set up sudo for local execution if no host specified
if sudo and not host:
from nssec.core.ssh import set_use_sudo
set_use_sudo(True)

if host:
_setup_remote_host(host, use_sudo=sudo)


# ─── SERVER COMMANDS ───
Expand Down Expand Up @@ -68,27 +119,8 @@ def _print_detection_results(info):


@server.command("detect")
@click.option(
"--host",
"-H",
help="Remote host to detect via SSH (e.g., user@hostname)",
)
def server_detect(host):
def server_detect():
"""Detect the NetSapiens server type."""
from nssec.core.cache import session_cache
from nssec.core.ssh import SSHExecutor, set_remote_host

if host:
console.print(f"[bold]Connecting to {host}...[/bold]")
executor = SSHExecutor(host)
success, message = executor.test_connection()
if not success:
console.print(f"[red]SSH connection failed: {message}[/red]")
raise SystemExit(1)
console.print(f"[green]{message}[/green]\n")
set_remote_host(host)
session_cache.clear()

info = get_server_info()

console.print(f"\n[bold cyan]Server Type:[/bold cyan] {info['server_type'].upper()}")
Expand Down
97 changes: 96 additions & 1 deletion src/nssec/cli/waf_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,25 @@ def waf_status():
console.print(f" [dim]{line}[/dim]")


@waf.command("allowlist")
@waf.group("allowlist", invoke_without_command=True)
@click.pass_context
def waf_allowlist(ctx):
"""Manage allowlisted IPs for reduced WAF strictness."""
if ctx.invoked_subcommand is None:
# Default behavior: show allowlist
from nssec.modules.waf import get_allowlisted_ips

ips = get_allowlisted_ips()
if not ips:
console.print("[dim]No IPs currently allowlisted.[/dim]")
return

console.print(f"[bold]Allowlisted IPs[/bold] ({len(ips)})\n")
for ip in ips:
console.print(f" {ip}")


@waf_allowlist.command("show")
def waf_allowlist_show():
"""Show current allowlisted IPs."""
from nssec.modules.waf import get_allowlisted_ips
Expand All @@ -305,3 +323,80 @@ def waf_allowlist_show():
console.print(f"[bold]Allowlisted IPs[/bold] ({len(ips)})\n")
for ip in ips:
console.print(f" {ip}")


@waf_allowlist.command("add")
@click.argument("ip")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def waf_allowlist_add(ip, yes):
"""Add an IP address to the WAF allowlist.

IP can be a single address (192.168.1.1) or CIDR notation (10.0.0.0/8).
Allowlisted IPs bypass OWASP CRS rules for reduced false positives.
"""
from nssec.modules.waf import ModSecurityInstaller, get_allowlisted_ips, add_allowlisted_ip

installer = ModSecurityInstaller()
pf = installer.preflight()
_require_root_and_modsec(pf, "sudo nssec waf allowlist add")

current_ips = get_allowlisted_ips()
if ip in current_ips:
console.print(f"[yellow]IP {ip} is already allowlisted.[/yellow]")
return

console.print(f"Adding [cyan]{ip}[/cyan] to WAF allowlist...")

result = add_allowlisted_ip(ip)
if not result.success:
console.print(f" [red]Error:[/red] {result.error}")
raise SystemExit(1)
console.print(f" [green]Done:[/green] {result.message}")

val = installer.validate_config()
if not val.success:
console.print(f" [red]Error:[/red] {val.error}")
raise SystemExit(1)
console.print(f" [green]Done:[/green] {val.message}")

_prompt_and_reload_apache(installer, yes)


@waf_allowlist.command("delete")
@click.argument("ip")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def waf_allowlist_delete(ip, yes):
"""Remove an IP address from the WAF allowlist.

IP must match exactly as it was added (including CIDR notation if used).
"""
from nssec.modules.waf import ModSecurityInstaller, get_allowlisted_ips, remove_allowlisted_ip

installer = ModSecurityInstaller()
pf = installer.preflight()
_require_root_and_modsec(pf, "sudo nssec waf allowlist delete")

current_ips = get_allowlisted_ips()
if ip not in current_ips:
console.print(f"[yellow]IP {ip} is not in the allowlist.[/yellow]")
if current_ips:
console.print("\nCurrent allowlisted IPs:")
for existing_ip in current_ips:
console.print(f" {existing_ip}")
return

console.print(f"Removing [cyan]{ip}[/cyan] from WAF allowlist...")

result = remove_allowlisted_ip(ip)
if not result.success:
console.print(f" [red]Error:[/red] {result.error}")
raise SystemExit(1)
console.print(f" [green]Done:[/green] {result.message}")

val = installer.validate_config()
if not val.success:
console.print(f" [red]Error:[/red] {val.error}")
raise SystemExit(1)
console.print(f" [green]Done:[/green] {val.message}")

_prompt_and_reload_apache(installer, yes)
11 changes: 9 additions & 2 deletions src/nssec/core/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
session_cache.clear()
"""

from __future__ import annotations

import threading
import time
from pathlib import Path
Expand All @@ -25,6 +27,11 @@
from nssec.core import ssh


def _remove_suffix(text: str, suffix: str) -> str:
"""Remove suffix from string (Python 3.8 compatible)."""
return text[:-len(suffix)] if suffix and text.endswith(suffix) else text


def _run_subprocess(cmd: list[str], timeout: int = 30) -> tuple[str, int]:
"""Run a subprocess command safely (locally or via SSH).

Expand Down Expand Up @@ -70,7 +77,7 @@ def _parse_service_line(line: str) -> tuple[Optional[str], Optional[str]]:
if not parts:
return None, None
service_unit = parts[0]
service_name = service_unit.removesuffix(".service")
service_name = _remove_suffix(service_unit, ".service")
return service_name, service_unit


Expand Down Expand Up @@ -273,7 +280,7 @@ def cached_service_active(self, service_name: str) -> bool:
if self._active_services is None or self._is_expired(self._services_time):
self._load_services_cache()

normalized = service_name.removesuffix(".service")
normalized = _remove_suffix(service_name, ".service")
return (
normalized in self._active_services
or f"{normalized}.service" in self._active_services
Expand Down
2 changes: 2 additions & 0 deletions src/nssec/core/checklist.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Security audit checklist engine."""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
Expand Down
2 changes: 2 additions & 0 deletions src/nssec/core/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
Where possible, checks read config files directly rather than running commands.
"""

from __future__ import annotations

from pathlib import Path

from nssec.core.cache import cached_ufw_rules
Expand Down
2 changes: 2 additions & 0 deletions src/nssec/core/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""YAML configuration management for nssec."""

from __future__ import annotations

import os
import re
from pathlib import Path
Expand Down
Loading