diff --git a/config/ns-audit.conf b/config/ns-audit.conf new file mode 100644 index 000000000..f93bbc3f9 --- /dev/null +++ b/config/ns-audit.conf @@ -0,0 +1 @@ +CONFIG_PACKAGE_ns-audit=y diff --git a/packages/ns-audit/Makefile b/packages/ns-audit/Makefile new file mode 100644 index 000000000..847a08424 --- /dev/null +++ b/packages/ns-audit/Makefile @@ -0,0 +1,63 @@ +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=ns-audit +PKG_VERSION:=0.1.0 +PKG_RELEASE:=1 + +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION) + +PKG_MAINTAINER:=Giacomo Sanchietti +PKG_LICENSE:=GPL-2.0-only + +include $(INCLUDE_DIR)/package.mk + +define Package/ns-audit + SECTION:=base + CATEGORY:=NethSecurity + TITLE:=NethSecurity on-device audit collector + URL:=https://github.com/NethServer/nethsecurity + DEPENDS:=+python3 +python3-nethsec +python3-jinja2 + PKGARCH:=all +endef + +define Package/ns-audit/description + Read-only local audit evidence collector for NethSecurity. +endef + +define Package/ns-audit/postinst +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/rpcd ]; then + /etc/init.d/rpcd restart +fi +exit 0 +endef + +define Package/ns-audit/postrm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/rpcd ]; then + /etc/init.d/rpcd restart +fi +exit 0 +endef + +# this is required, otherwise compile will fail +define Build/Compile +endef + +define Package/ns-audit/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/ns-audit.py $(1)/usr/sbin/ns-audit + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./files/ns.audit $(1)/usr/libexec/rpcd/ns.audit + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./files/ns.audit.json $(1)/usr/share/rpcd/acl.d/ns.audit.json + $(INSTALL_DIR) $(1)/usr/share/ns-audit + $(CP) ./files/ns_audit $(1)/usr/share/ns-audit/ +endef + +$(eval $(call BuildPackage,ns-audit)) diff --git a/packages/ns-audit/README.md b/packages/ns-audit/README.md new file mode 100644 index 000000000..04546e043 --- /dev/null +++ b/packages/ns-audit/README.md @@ -0,0 +1,142 @@ +# ns-audit + +On-device NethSecurity audit reporting package. + +This package provides a read-only local audit pipeline for NethSecurity. It +collects sanitized evidence, runs the available analyzers, renders +`audit-report.html` with Jinja2, packages it into `audit-report.tar.gz`, and +exposes an optional read-only `ns.audit` RPCD endpoint. It does not generate +PDF files or change appliance configuration. + +## Execution model + +- Runs locally on the NethSecurity appliance. +- Does not use SSH, Paramiko, remote collectors, or external report services. +- Does not write UCI configuration, call `uci commit`, restart services, or + modify `/etc/config/*`. +- Reads local configuration and runtime state to build sanitized JSON artifacts. +- Produces HTML, JSON, and tar.gz bundle artifacts; PDF output is intentionally unsupported. +- The RPCD endpoint only writes report artifacts below + `/var/run/ns-audit//`. + +Expected input directory: + +```text +/var/run/ns-audit// + raw_snapshot.json + inventory.json + findings.json + compliance_mapping.json + summary.json +``` + +Report rendering: + +```bash +PYTHONPATH=/usr/share/ns-audit python3 -m ns_audit.reporting.html render \ + --input /var/run/ns-audit/latest \ + --output /var/run/ns-audit/latest/audit-report.html +``` + +From a repository checkout, use `PYTHONPATH=packages/ns-audit/files` instead. +The top-level CLI is expected to wrap the same renderer as: + +```bash +ns-audit report --input /var/run/ns-audit/latest \ + --output /var/run/ns-audit/latest/audit-report.html +ns-audit report --input /var/run/ns-audit/latest \ + --output /var/run/ns-audit/latest/audit-report.tar.gz +``` + +The tarball form renders the HTML report into the same output directory and then +packages the HTML, JSON artifacts, and deduplicated log files. + +## Outputs + +- `audit-report.html`: HTML report with embedded CSS and links to collected logs. +- `audit-report.tar.gz`: bundle containing the HTML report, JSON artifacts, and deduplicated log copies under `logs/`. +- Existing JSON artifacts remain the source of truth: + - `raw_snapshot.json` + - `inventory.json` + - `findings.json` + - `compliance_mapping.json` + - `summary.json` + +The HTML report includes: + +1. Executive Summary +2. Configuration Inventory +3. Gap Analysis & Compliance Mapping +4. Actionable Remediation Plan +5. Collected Logs +6. Evidence Appendix + +## Validation + +Render-time validation refuses to write HTML if known private-key, password-hash, +preshared-key, or token patterns are detected. + +Validate all generated artifacts: + +```bash +PYTHONPATH=/usr/share/ns-audit python3 -m ns_audit.reporting.html validate /var/run/ns-audit/latest +``` + +The command returns JSON: + +```json +{"matches": [], "status": "ok"} +``` + +## Package installation notes + +The package integration installs the CLI, Python modules, reporting assets, and +the optional RPCD wrapper: + +- depending on `+python3-jinja2`; +- installing `files/ns-audit.py` as `/usr/sbin/ns-audit`; +- installing `files/ns_audit/reporting/*.py` as Python module files; +- installing `files/ns_audit/reporting/templates/audit-report.html.j2`; +- installing `files/ns_audit/reporting/assets/audit-report.css`; +- installing `/usr/libexec/rpcd/ns.audit` and its ACL file; +- restarting `rpcd` on live package install/removal so the endpoint is + registered. + +If a downstream branch does not have an `ns-audit` Makefile yet, add equivalent +install rules when the package skeleton is introduced. + +## RPCD wrapper + +The package installs `/usr/libexec/rpcd/ns.audit` and +`/usr/share/rpcd/acl.d/ns.audit.json`. The endpoint is read-only with respect to +appliance configuration: it does not write UCI configuration, call +`uci commit`, or restart services. + +Methods: + +- `generate`: optionally accepts `report_id`, runs the installed + `ns_audit.cli.run(output_dir)` pipeline, and returns `report_id`, + `output_dir`, generated artifacts (including `audit-report.html` and + `audit-report.tar.gz`), and `summary` when readable. +- `list-reports`: lists safe report directories under `/var/run/ns-audit`. +- `get-summary`: requires `report_id` and returns the matching `summary.json`. + +`report_id` must match `[A-Za-z0-9_.-]+`; if omitted by `generate`, a +timestamped ID is generated. Arbitrary output paths are not accepted. + +Examples: + +```bash +ubus call ns.audit generate '{}' +ubus call ns.audit generate '{"report_id":"manual-2026-01-01"}' +ubus call ns.audit list-reports '{}' +ubus call ns.audit get-summary '{"report_id":"manual-2026-01-01"}' +``` + +The same methods are available through `api-cli`: + +```bash +api-cli ns.audit generate --data '{}' +api-cli ns.audit list-reports +api-cli ns.audit get-summary --data '{"report_id":"manual-2026-01-01"}' +``` diff --git a/packages/ns-audit/files/ns-audit.py b/packages/ns-audit/files/ns-audit.py new file mode 100755 index 000000000..6eab69ebc --- /dev/null +++ b/packages/ns-audit/files/ns-audit.py @@ -0,0 +1,27 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +import os +import sys + + +SHARE_DIR = "/usr/share/ns-audit" +LOCAL_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def main() -> int: + if os.path.isdir(os.path.join(SHARE_DIR, "ns_audit")): + sys.path.insert(0, SHARE_DIR) + else: + sys.path.insert(0, LOCAL_DIR) + + from ns_audit.cli import main as cli_main + + return cli_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/ns-audit/files/ns.audit b/packages/ns-audit/files/ns.audit new file mode 100755 index 000000000..a9e1f8978 --- /dev/null +++ b/packages/ns-audit/files/ns.audit @@ -0,0 +1,309 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +import json +import re +import sys +from datetime import UTC, datetime +from pathlib import Path + +REPORT_ROOT = Path("/var/run/ns-audit") +INSTALLED_SHARE_DIR = Path("/usr/share/ns-audit") +LOCAL_SHARE_DIR = Path(__file__).resolve().parent +REPORT_ID_RE = re.compile(r"[A-Za-z0-9_.-]+") + +ARTIFACT_NAMES = ( + "raw_snapshot.json", + "inventory.json", + "findings.json", + "compliance_mapping.json", + "summary.json", + "audit-report.html", + "audit-report.tar.gz", +) +METHODS = { + "generate": {"report_id": "String"}, + "list-reports": {}, + "get-summary": {"report_id": "String"}, +} + + +class RpcdError(Exception): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +class ValidationError(Exception): + def __init__(self, parameter: str, message: str, value: object = None): + super().__init__(message) + self.parameter = parameter + self.message = message + self.value = value + + +def _json_default(value: object) -> str: + return str(value) + + +def _write_response(payload: object) -> None: + print( + json.dumps(payload, ensure_ascii=False, sort_keys=True, default=_json_default) + ) + + +def _error(code: str) -> dict[str, str]: + return {"error": code} + + +def _validation_error( + exc: ValidationError, +) -> dict[str, dict[str, list[dict[str, object]]]]: + return { + "validation": { + "errors": [ + { + "parameter": exc.parameter, + "message": exc.message, + "value": exc.value, + } + ] + } + } + + +def _setup_python_path() -> None: + for share_dir in (INSTALLED_SHARE_DIR, LOCAL_SHARE_DIR): + if (share_dir / "ns_audit").is_dir(): + sys.path.insert(0, str(share_dir)) + + +def _load_run_pipeline(): + _setup_python_path() + try: + from ns_audit.cli import run + except ImportError as exc: + raise RpcdError("audit_pipeline_unavailable") from exc + return run + + +def _read_payload() -> dict[str, object]: + raw_payload = sys.stdin.read() + try: + payload = json.loads(raw_payload) if raw_payload.strip() else {} + except json.JSONDecodeError as exc: + raise RpcdError("invalid_json") from exc + if not isinstance(payload, dict): + raise ValidationError("payload", "expected_object", payload) + return payload + + +def _is_safe_report_id(report_id: str) -> bool: + return bool(REPORT_ID_RE.fullmatch(report_id)) and report_id not in {".", ".."} + + +def _timestamped_report_id() -> str: + return f"audit-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S_%fZ')}" + + +def _report_id_from_payload(payload: dict[str, object], *, required: bool) -> str: + value = payload.get("report_id") + if value in (None, ""): + if required: + raise ValidationError("report_id", "required", value) + return _timestamped_report_id() + if not isinstance(value, str) or not _is_safe_report_id(value): + raise ValidationError("report_id", "invalid_report_id", value) + return value + + +def _ensure_report_root() -> Path: + if REPORT_ROOT.is_symlink(): + raise RpcdError("output_root_unsafe") + REPORT_ROOT.mkdir(parents=True, exist_ok=True) + if not REPORT_ROOT.is_dir(): + raise RpcdError("output_root_invalid") + return REPORT_ROOT + + +def _ensure_report_dir(report_id: str) -> Path: + report_root = _ensure_report_root() + output_dir = report_root / report_id + if output_dir.is_symlink(): + raise RpcdError("output_dir_unsafe") + output_dir.mkdir(parents=True, exist_ok=True) + if not output_dir.is_dir(): + raise RpcdError("output_dir_invalid") + return output_dir + + +def _existing_report_dir(report_id: str) -> Path: + if REPORT_ROOT.is_symlink(): + raise RpcdError("output_root_unsafe") + if not REPORT_ROOT.exists(): + raise RpcdError("report_not_found") + if not REPORT_ROOT.is_dir(): + raise RpcdError("output_root_invalid") + report_dir = REPORT_ROOT / report_id + if report_dir.is_symlink(): + raise RpcdError("output_dir_unsafe") + if not report_dir.is_dir(): + raise RpcdError("report_not_found") + return report_dir + + +def _artifact_info(path: Path) -> dict[str, object] | None: + if path.is_symlink() or not path.is_file(): + return None + try: + stat = path.stat() + except OSError: + return None + return { + "name": path.name, + "path": str(path), + "size": stat.st_size, + "mtime": int(stat.st_mtime), + } + + +def _list_artifacts(report_dir: Path) -> list[dict[str, object]]: + artifacts = [] + for name in ARTIFACT_NAMES: + artifact = _artifact_info(report_dir / name) + if artifact is not None: + artifacts.append(artifact) + return artifacts + + +def _read_summary(report_dir: Path, *, required: bool) -> dict[str, object] | None: + summary_path = report_dir / "summary.json" + if summary_path.is_symlink(): + if required: + raise RpcdError("summary_not_readable") + return None + if not summary_path.is_file(): + if required: + raise RpcdError("summary_not_found") + return None + try: + with summary_path.open(encoding="utf-8") as handle: + summary = json.load(handle) + except json.JSONDecodeError as exc: + if required: + raise RpcdError("invalid_summary") from exc + return None + except OSError as exc: + if required: + raise RpcdError("summary_not_readable") from exc + return None + if not isinstance(summary, dict): + if required: + raise RpcdError("invalid_summary") + return None + return summary + + +def generate(payload: dict[str, object]) -> dict[str, object]: + report_id = _report_id_from_payload(payload, required=False) + output_dir = _ensure_report_dir(report_id) + _load_run_pipeline()(output_dir) + + response: dict[str, object] = { + "report_id": report_id, + "output_dir": str(output_dir), + "artifacts": _list_artifacts(output_dir), + } + summary = _read_summary(output_dir, required=False) + if summary is not None: + response["summary"] = summary + return response + + +def list_reports(_payload: dict[str, object]) -> dict[str, list[dict[str, object]]]: + if not REPORT_ROOT.exists(): + return {"reports": []} + if REPORT_ROOT.is_symlink() or not REPORT_ROOT.is_dir(): + raise RpcdError("output_root_invalid") + + reports = [] + for report_dir in sorted(REPORT_ROOT.iterdir(), key=lambda path: path.name): + if ( + not _is_safe_report_id(report_dir.name) + or report_dir.is_symlink() + or not report_dir.is_dir() + ): + continue + try: + stat = report_dir.stat() + except OSError: + continue + reports.append( + { + "report_id": report_dir.name, + "output_dir": str(report_dir), + "mtime": int(stat.st_mtime), + "artifacts": _list_artifacts(report_dir), + } + ) + return {"reports": reports} + + +def get_summary(payload: dict[str, object]) -> dict[str, object]: + report_id = _report_id_from_payload(payload, required=True) + report_dir = _existing_report_dir(report_id) + return { + "report_id": report_id, + "output_dir": str(report_dir), + "summary": _read_summary(report_dir, required=True), + } + + +def _call_method(method: str, payload: dict[str, object]) -> dict[str, object]: + if method == "generate": + return generate(payload) + if method == "list-reports": + return list_reports(payload) + if method == "get-summary": + return get_summary(payload) + raise RpcdError("unknown_method") + + +def main() -> int: + if len(sys.argv) < 2: + _write_response(_error("missing_command")) + return 0 + + command = sys.argv[1] + if command == "list": + _write_response(METHODS) + return 0 + if command != "call": + _write_response(_error("unknown_command")) + return 0 + if len(sys.argv) < 3: + _write_response(_error("missing_method")) + return 0 + + try: + _write_response(_call_method(sys.argv[2], _read_payload())) + except ValidationError as exc: + _write_response(_validation_error(exc)) + except RpcdError as exc: + _write_response(_error(exc.code)) + except OSError: + _write_response(_error("io_error")) + except RuntimeError: + _write_response(_error("report_generation_failed")) + except ValueError: + _write_response(_error("invalid_value")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/ns-audit/files/ns.audit.json b/packages/ns-audit/files/ns.audit.json new file mode 100644 index 000000000..ae643eb24 --- /dev/null +++ b/packages/ns-audit/files/ns.audit.json @@ -0,0 +1,15 @@ +{ + "audit-reader": { + "description": "Generate and read local audit reports", + "write": {}, + "read": { + "ubus": { + "ns.audit": [ + "generate", + "list-reports", + "get-summary" + ] + } + } + } +} diff --git a/packages/ns-audit/files/ns_audit/__init__.py b/packages/ns-audit/files/ns_audit/__init__.py new file mode 100644 index 000000000..fc35fa911 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +__version__ = "0.1.0" diff --git a/packages/ns-audit/files/ns_audit/analyzers/__init__.py b/packages/ns-audit/files/ns_audit/analyzers/__init__.py new file mode 100644 index 000000000..981e5698b --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/__init__.py @@ -0,0 +1,91 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Normalization and analysis entry points for ns-audit.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from ..compliance import build_compliance_mapping, build_summary + from ..models import json_safe, severity_counts +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.compliance import build_compliance_mapping, build_summary + from ns_audit.models import json_safe, severity_counts + +from . import ( + api_audit, + banip, + firewall, + identity, + ips, + logging as logging_analyzer, + telemetry, + updates, + vpn, +) + +ANALYZERS = (identity, firewall, vpn, ips, logging_analyzer, telemetry, api_audit, banip, updates) + + +def analyze_snapshot(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Analyze a sanitized raw snapshot and return all JSON report structures.""" + inventory: dict[str, Any] = {} + findings: list[dict[str, Any]] = [] + area_summaries: dict[str, Any] = {} + for analyzer in ANALYZERS: + result = analyzer.analyze(raw_snapshot) + result_inventory = ( + result.get("inventory", {}) if isinstance(result, Mapping) else {} + ) + result_findings = ( + result.get("findings", []) if isinstance(result, Mapping) else [] + ) + result_summary = ( + result.get("summary", {}) if isinstance(result, Mapping) else {} + ) + if isinstance(result_inventory, Mapping): + inventory.update(result_inventory) + if isinstance(result_findings, list): + findings.extend(result_findings) + if isinstance(result_summary, Mapping): + area_summaries.update(result_summary) + compliance_mapping = build_compliance_mapping(findings) + summary = build_summary(inventory, findings) + summary["area_summaries"] = area_summaries + return json_safe( + { + "inventory": inventory, + "findings": findings, + "summary": summary, + "compliance_mapping": compliance_mapping, + "compliance": compliance_mapping, + } + ) + + +def build_analysis_outputs(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Return structures suitable for inventory/findings/summary/compliance JSON files.""" + analysis = analyze_snapshot(raw_snapshot) + findings = analysis["findings"] + return json_safe( + { + "inventory": analysis["inventory"], + "findings": { + "findings": findings, + "finding_counts": severity_counts(findings), + "finding_count": len(findings), + }, + "summary": analysis["summary"], + "compliance_mapping": analysis["compliance_mapping"], + } + ) + + +analyze = analyze_snapshot +__all__ = ["analyze", "analyze_snapshot", "build_analysis_outputs"] diff --git a/packages/ns-audit/files/ns_audit/analyzers/api_audit.py b/packages/ns-audit/files/ns_audit/analyzers/api_audit.py new file mode 100644 index 000000000..dac8be2e1 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/api_audit.py @@ -0,0 +1,291 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""NethSecurity API audit trail analyzer. + +Parses nethsecurity-api log entries from /var/log/messages to extract: +- Admin login and logout events (timestamp, user, source IP) +- Authentication failures (unauthorized requests, invalid signatures) +- Configuration changes committed via ns.commit (with diff payload) +- Recent admin API actions +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Mapping, Sequence +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + json_safe, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + json_safe, + ) + +AREA = "api_audit" + +# nethsecurity-api log line patterns +_RE_API_LINE = re.compile(r"nethsecurity.api\[", re.I) +_RE_AUTH_SUCCESS = re.compile( + r"authentication success for user (\S+) from ([\d.:]+)", re.I +) +_RE_LOGIN_RESPONSE = re.compile(r"login response success for user (\S+)", re.I) +_RE_LOGOUT = re.compile(r"logout response success for user (\S+)", re.I) +_RE_UNAUTHORIZED = re.compile(r"unauthorized request[:\s]+(.+)", re.I) +_RE_AUTH_FAILURE = re.compile(r"authentication fail(?:ure|ed) for user (\S+)", re.I) +_RE_AUTHORIZATION = re.compile( + r"authorization success for user (\S+)\.\s+\w+\s+/api/ubus/call\s+(\{.+\})", re.I +) +_RE_SYSLOG_TIMESTAMP = re.compile(r"^(\w+\s+\d+\s+[\d:]+)") + + +def _extract_timestamp(line: str) -> str: + m = _RE_SYSLOG_TIMESTAMP.match(line) + return m.group(1) if m else "" + + +def _parse_api_payload(json_text: str) -> dict[str, Any]: + try: + return json.loads(json_text) if json_text else {} + except (json.JSONDecodeError, ValueError): + return {} + + +def _commit_description(payload: dict[str, Any]) -> str: + """Build a human-readable description of a ns.commit change payload.""" + changes = payload.get("payload", {}).get("changes", {}) + if not changes: + return "" + parts = [] + for pkg, ops in changes.items(): + if not isinstance(ops, list): + continue + for op in ops: + if not isinstance(op, (list, tuple)) or len(op) < 2: + continue + op_type = op[0] + if op_type == "set" and len(op) >= 4: + parts.append(f"{pkg}.{op[1]}.{op[2]} = {op[3]}") + elif op_type == "delete" and len(op) >= 3: + parts.append(f"del {pkg}.{op[1]}.{op[2]}") + elif op_type == "add" and len(op) >= 3: + parts.append(f"add {pkg}.{op[1]} ({op[2]})") + else: + parts.append(f"{pkg}: {op_type}") + return "; ".join(parts[:8]) + + +def _collect_log_lines(raw_snapshot: Mapping[str, Any]) -> list[str]: + """Return all log lines from the collected log evidence. + + Reads from logread, primary files, and secondary_files so that API audit + events are not missed even when storage is the active bundle source. + """ + logs = raw_snapshot.get("logs") + if not isinstance(logs, Mapping): + return [] + seen: set[str] = set() + lines: list[str] = [] + + def _add_lines(text: str) -> None: + for ln in text.splitlines(): + if ln not in seen: + seen.add(ln) + lines.append(ln) + + logread = logs.get("logread") + if isinstance(logread, Mapping): + stdout = logread.get("stdout") + if isinstance(stdout, str): + _add_lines(stdout) + + for key in ("files", "secondary_files"): + for entry in logs.get(key) or []: + if isinstance(entry, Mapping): + content = entry.get("content") + if isinstance(content, str): + _add_lines(content) + + return lines + + +def _parse_events(log_lines: Sequence[str]) -> dict[str, list[dict[str, Any]]]: + logins: list[dict[str, Any]] = [] + logouts: list[dict[str, Any]] = [] + auth_failures: list[dict[str, Any]] = [] + config_changes: list[dict[str, Any]] = [] + + for line in log_lines: + if not _RE_API_LINE.search(line): + continue + ts = _extract_timestamp(line) + + m = _RE_AUTH_SUCCESS.search(line) + if m: + logins.append( + { + "timestamp": ts, + "user": m.group(1), + "source_ip": m.group(2), + "type": "login", + } + ) + continue + + m = _RE_AUTH_FAILURE.search(line) + if m: + auth_failures.append( + { + "timestamp": ts, + "user": m.group(1), + "reason": "authentication_failure", + } + ) + continue + + m = _RE_UNAUTHORIZED.search(line) + if m: + auth_failures.append( + {"timestamp": ts, "user": "", "reason": m.group(1).strip()} + ) + continue + + m = _RE_LOGOUT.search(line) + if m: + logouts.append({"timestamp": ts, "user": m.group(1)}) + continue + + m = _RE_AUTHORIZATION.search(line) + if m: + user = m.group(1) + payload = _parse_api_payload(m.group(2)) + path = payload.get("path", "") + method = payload.get("method", "") + if path == "ns.commit" and method == "commit": + desc = _commit_description(payload) + config_changes.append( + { + "timestamp": ts, + "user": user, + "path": path, + "method": method, + "description": desc, + "raw_payload": json.dumps( + payload.get("payload", {}), ensure_ascii=False + )[:300], + } + ) + + return { + "logins": logins[-50:], + "logouts": logouts[-50:], + "auth_failures": auth_failures[-100:], + "config_changes": config_changes[-50:], + } + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + log_lines = _collect_log_lines(raw_snapshot) + api_lines = [ln for ln in log_lines if _RE_API_LINE.search(ln)] + events = _parse_events(log_lines) + + logins = events["logins"] + logouts = events["logouts"] + auth_failures = events["auth_failures"] + config_changes = events["config_changes"] + + inventory = { + "api_log_lines": len(api_lines), + "login_events": logins, + "logout_events": logouts, + "auth_failure_events": auth_failures, + "config_change_events": config_changes, + "counts": { + "logins": len(logins), + "logouts": len(logouts), + "auth_failures": len(auth_failures), + "config_changes": len(config_changes), + }, + } + + findings: list[dict[str, Any]] = [] + + if not api_lines: + findings.append( + build_finding( + "api-audit-no-log-evidence", + "NethSecurity API log entries not found", + "medium", + AREA, + "api_logs", + "No nethsecurity-api entries were found in the sampled log lines. " + "API login, logout, and config-change events cannot be audited.", + {}, + "Verify rsyslog is forwarding nethsecurity-api messages and that the log " + "buffer is large enough to retain recent activity.", + nist=("AU-2", "AU-3", "AU-6"), + acn=("logging_monitoring", "identity_access_governance"), + ) + ) + + if len(auth_failures) >= 20: + severity = "high" + elif len(auth_failures) >= 5: + severity = "medium" + else: + severity = "" + if severity: + findings.append( + build_finding( + "api-audit-auth-failures", + "Multiple API authentication failures detected", + severity, + AREA, + "api_auth", + f"{len(auth_failures)} unauthorized or failed authentication events were found " + "in the sampled API log lines.", + {"auth_failure_count": len(auth_failures), "sample": auth_failures[:3]}, + "Investigate source addresses and consider enabling account lockout, " + "rate limiting, or IP allowlisting for the management API.", + nist=("AC-7", "AU-6", "IA-2", "SI-4"), + acn=( + "identity_access_governance", + "logging_monitoring", + "threat_detection", + ), + ) + ) + + if config_changes: + findings.append( + build_finding( + "api-audit-config-changes-detected", + "Configuration changes committed via API", + "info", + AREA, + "api_config", + f"{len(config_changes)} ns.commit operations were found in the sampled log. " + "Each entry records the user, timestamp, and exact UCI change payload.", + { + "config_change_count": len(config_changes), + "sample": config_changes[:3], + }, + "Review configuration change events periodically and ensure all changes " + "are authorized and traceable to a named administrator account.", + nist=("AU-2", "AU-3", "CM-3", "CM-5"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + return build_result(AREA, json_safe(inventory), findings) diff --git a/packages/ns-audit/files/ns_audit/analyzers/banip.py b/packages/ns-audit/files/ns_audit/analyzers/banip.py new file mode 100644 index 000000000..bd0c57079 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/banip.py @@ -0,0 +1,159 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""BanIP threat-feed and rate-limiting configuration analyzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + option_value, + string_list, + to_bool, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + option_value, + string_list, + to_bool, + uci_sections, + ) + +AREA = "banip" + + +def _banip_global(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + sections = uci_sections(raw_snapshot, "banip") + main = {} + for section in sections: + name = str(section.get(".name", "") or section.get("name", "")).strip() + if name == "global" or not main: + main = section + if not main: + return {"present": False} + + feeds = string_list(option_value(main, "ban_feed", [])) + return { + "present": True, + "enabled": to_bool(option_value(main, "ban_enabled", "0"), default=False), + "loglimit": str(option_value(main, "ban_loglimit", "") or "").strip(), + "icmplimit": str(option_value(main, "ban_icmplimit", "") or "").strip(), + "synlimit": str(option_value(main, "ban_synlimit", "") or "").strip(), + "udplimit": str(option_value(main, "ban_udplimit", "") or "").strip(), + "ban_feeds": feeds, + } + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + cfg = _banip_global(raw_snapshot) + inventory = {"banip": cfg} + findings = [] + + if not cfg.get("present"): + findings.append( + build_finding( + "banip-not-installed", + "BanIP is not installed", + "medium", + AREA, + "banip", + "BanIP provides automated IP blocklisting using threat feeds and rate-limiting. " + "No BanIP configuration was found in the snapshot.", + {}, + "Install and enable BanIP to block known malicious IPs and rate-limit ICMP/SYN/UDP traffic.", + nist=("SC-5", "SC-7", "SI-3", "SI-4"), + acn=("threat_detection", "network_segmentation", "risk_management"), + ) + ) + return build_result(AREA, inventory, findings) + + if not cfg.get("enabled"): + findings.append( + build_finding( + "banip-disabled", + "BanIP is installed but not enabled", + "medium", + AREA, + "banip", + "BanIP is present in the configuration but ban_enabled is not set to 1. " + "Threat-feed blocking and rate-limiting are inactive.", + {"ban_enabled": cfg.get("enabled")}, + "Set banip.global.ban_enabled=1 and configure at least one threat feed to activate protection.", + nist=("SC-5", "SC-7", "SI-3", "SI-4"), + acn=("threat_detection", "network_segmentation", "risk_management"), + ) + ) + return build_result(AREA, inventory, findings) + + # BanIP is enabled — check individual hardening options + if not cfg.get("loglimit"): + findings.append( + build_finding( + "banip-no-loglimit", + "BanIP log rate limit not configured", + "low", + AREA, + "banip", + "ban_loglimit is not set. Without a log rate limit, banip may generate excessive log entries under heavy attack.", + {}, + "Set banip.global.ban_loglimit to a reasonable value (e.g., 100) to prevent log flooding.", + nist=("AU-9", "AU-11", "SC-5"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + rate_limits = [ + ("icmplimit", "ban_icmplimit", "ICMP"), + ("synlimit", "ban_synlimit", "SYN"), + ("udplimit", "ban_udplimit", "UDP"), + ] + for key, uci_key, proto in rate_limits: + if not cfg.get(key): + findings.append( + build_finding( + f"banip-no-{key}", + f"BanIP {proto} rate limit not configured", + "low", + AREA, + "banip", + f"{uci_key} is not set. {proto} flood attacks may not be rate-limited.", + {}, + f"Set banip.global.{uci_key}=1 to enable {proto} rate limiting.", + nist=("SC-5", "SC-7"), + acn=("threat_detection", "network_segmentation"), + ) + ) + + if not cfg.get("ban_feeds"): + findings.append( + build_finding( + "banip-no-feeds", + "No threat intelligence feeds configured in BanIP", + "high", + AREA, + "banip", + "BanIP is enabled but no ban_feed entries are configured. Without feeds, " + "no IP blocklisting is active against known threat actors.", + {}, + "Add at least one threat feed (e.g., debl, firehol1, etcompromised) via: " + "uci add_list banip.global.ban_feed=debl", + nist=("SC-5", "SC-7", "SI-3", "SI-4"), + acn=("threat_detection", "risk_management"), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_banip = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/firewall.py b/packages/ns-audit/files/ns_audit/analyzers/firewall.py new file mode 100644 index 000000000..6334012e1 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/firewall.py @@ -0,0 +1,387 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Firewall zones, forwarding, redirect, and exposure analyzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + is_enabled_section, + option_value, + section_name, + section_type, + string_list, + to_bool, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + is_enabled_section, + option_value, + section_name, + section_type, + string_list, + to_bool, + uci_sections, + ) + +AREA = "firewall" +WAN_NAMES = {"wan", "wan6", "wwan", "ns_wan", "ns_wan6"} +INTERNAL_NAMES = { + "lan", + "guest", + "dmz", + "mgmt", + "ns_lan", + "ns_guest", + "ns_dmz", + "ns_mgmt", +} +# Ports that indicate direct administrative service exposure from WAN (not HTTPS proxy) +ADMIN_PORTS_STRICT = {"22", "80", "4443", "8000", "8080", "8443", "9090", "9091"} +# 443 is treated separately as it commonly hosts legitimate HTTPS proxy pass +HTTPS_PORTS = {"443", "8443"} +# ICMP protocols — WAN-facing ICMP (ping) is intentional and not a risk +ICMP_PROTOS = {"icmp", "icmp6", "ipv6-icmp"} + + +def _zone_name(section: Mapping[str, Any]) -> str: + return str(option_value(section, "name", section_name(section))).strip() + + +def _is_wan_zone(zone: Mapping[str, Any]) -> bool: + name = str(zone.get("name", "")).lower() + networks = {str(network).lower() for network in zone.get("networks", [])} + return ( + name in WAN_NAMES + or bool(networks & WAN_NAMES) + or to_bool(zone.get("masq"), default=False) + ) + + +def _ports(value: Any) -> list[str]: + ports = [] + for item in string_list(value): + if ":" in item: + ports.extend(part for part in item.split(":") if part) + else: + ports.append(item) + return sorted(set(ports)) + + +def _port_is_admin(ports: list[str]) -> bool: + if not ports: + return False + for port in ports: + if port in ADMIN_PORTS_STRICT: + return True + if "-" in port: + start, _, end = port.partition("-") + if ( + start.isdigit() + and end.isdigit() + and any(int(start) <= int(admin) <= int(end) for admin in ADMIN_PORTS_STRICT) + ): + return True + return False + + +def _port_is_https_only(ports: list[str]) -> bool: + """True when the only exposed ports are HTTPS (443/8443) — likely proxy pass.""" + return bool(ports) and all(p in HTTPS_PORTS for p in ports) + + +def _is_icmp_only(proto_list: list[str]) -> bool: + """True when the rule only covers ICMP protocols (ping is expected from WAN).""" + return bool(proto_list) and all(p.lower() in ICMP_PROTOS for p in proto_list) + + +def _normalize_firewall(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + zones = [] + forwardings = [] + redirects = [] + rules = [] + for section in uci_sections(raw_snapshot, "firewall"): + stype = section_type(section) + if stype == "zone": + zones.append( + { + "id": section_name(section), + "name": _zone_name(section), + "networks": string_list( + option_value(section, ("network", "networks"), []) + ), + "input": str(option_value(section, "input", "")).upper(), + "output": str(option_value(section, "output", "")).upper(), + "forward": str(option_value(section, "forward", "")).upper(), + "masq": to_bool(option_value(section, "masq"), default=False), + "mtu_fix": to_bool(option_value(section, "mtu_fix"), default=False), + "enabled": is_enabled_section(section, default=True), + } + ) + elif stype == "forwarding": + forwardings.append( + { + "id": section_name(section), + "src": str(option_value(section, "src", "")), + "dest": str(option_value(section, "dest", "")), + "enabled": is_enabled_section(section, default=True), + } + ) + elif stype == "redirect": + ports = _ports( + option_value(section, ("src_dport", "src_port", "dest_port"), []) + ) + redirects.append( + { + "id": section_name(section), + "name": str(option_value(section, "name", section_name(section))), + "src": str(option_value(section, "src", "")), + "dest": str(option_value(section, "dest", "")), + "proto": string_list(option_value(section, "proto", [])), + "src_dport": ports, + "dest_ip_present": bool(option_value(section, "dest_ip")), + "dest_port": _ports(option_value(section, "dest_port", [])), + "target": str(option_value(section, "target", "DNAT")).upper(), + "enabled": is_enabled_section(section, default=True), + } + ) + elif stype == "rule": + proto_list = string_list(option_value(section, "proto", [])) + rules.append( + { + "id": section_name(section), + "name": str(option_value(section, "name", section_name(section))), + "src": str(option_value(section, "src", "")), + "dest": str(option_value(section, "dest", "")), + "proto": proto_list, + "icmp_only": _is_icmp_only(proto_list), + "dest_port": _ports( + option_value(section, ("dest_port", "src_dport"), []) + ), + "target": str(option_value(section, "target", "")).upper(), + "family": str(option_value(section, "family", "any")), + "system_rule": to_bool( + option_value(section, "system_rule"), default=False + ), + "enabled": is_enabled_section(section, default=True), + } + ) + wan_zones = {zone["name"] for zone in zones if _is_wan_zone(zone)} + exposed_services = [] + for rule in rules: + if not rule.get("enabled") or rule.get("target") != "ACCEPT": + continue + # Skip ICMP-only rules — WAN ping (echo-request) is intentional and not a risk + if rule.get("icmp_only"): + continue + if rule.get("src") in wan_zones or str(rule.get("src")).lower() in WAN_NAMES: + ports = rule.get("dest_port", []) + exposed_services.append( + { + "type": "rule", + "id": rule.get("id"), + "name": rule.get("name"), + "ports": ports, + "admin_port": _port_is_admin(ports), + "https_only": _port_is_https_only(ports), + } + ) + for redirect in redirects: + if not redirect.get("enabled"): + continue + if ( + redirect.get("src") in wan_zones + or str(redirect.get("src")).lower() in WAN_NAMES + ): + ports = redirect.get("src_dport", []) + exposed_services.append( + { + "type": "redirect", + "id": redirect.get("id"), + "name": redirect.get("name"), + "ports": ports, + "admin_port": _port_is_admin(ports), + "https_only": _port_is_https_only(ports), + } + ) + return { + "zones": zones, + "wan_zones": sorted(wan_zones), + "forwardings": forwardings, + "redirects": redirects, + "rules": rules, + "exposed_services": exposed_services, + } + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + inventory = _normalize_firewall(raw_snapshot) + findings = [] + wan_zones = set(inventory["wan_zones"]) + + if not inventory["zones"]: + findings.append( + build_finding( + "firewall-no-zones", + "No firewall zones found in snapshot", + "high", + AREA, + "firewall", + "The sanitized snapshot did not contain firewall zone definitions, preventing boundary validation.", + {}, + "Verify that /etc/config/firewall is collected and define explicit zones with default-deny WAN policy.", + nist=("AC-4", "CM-6", "SC-7"), + acn=("network_segmentation", "risk_management"), + ) + ) + + for zone in inventory["zones"]: + if not zone.get("enabled", True): + continue + if zone.get("name") in wan_zones and zone.get("input") == "ACCEPT": + findings.append( + build_finding( + f"firewall-wan-input-accept-{zone['name']}", + "WAN zone accepts inbound traffic by default", + "critical", + AREA, + "zone", + "A WAN-like zone has input policy ACCEPT, allowing unsolicited traffic unless later rules block it.", + { + "zone": zone.get("name"), + "input": zone.get("input"), + "networks": zone.get("networks"), + }, + "Set WAN input policy to REJECT or DROP and allow only explicitly required services.", + nist=("AC-4", "CM-7", "SC-7"), + acn=("network_segmentation", "risk_management"), + ) + ) + if zone.get("name") in wan_zones and zone.get("forward") == "ACCEPT": + findings.append( + build_finding( + f"firewall-wan-forward-accept-{zone['name']}", + "WAN zone forwards traffic by default", + "high", + AREA, + "zone", + "A WAN-like zone has forwarding policy ACCEPT, weakening boundary enforcement.", + {"zone": zone.get("name"), "forward": zone.get("forward")}, + "Set WAN forward policy to REJECT or DROP and create scoped forwarding rules only where needed.", + nist=("AC-4", "CM-7", "SC-7"), + acn=("network_segmentation",), + ) + ) + + for forwarding in inventory["forwardings"]: + if not forwarding.get("enabled", True): + continue + src = str(forwarding.get("src")) + dest = str(forwarding.get("dest")) + if src in wan_zones and dest not in wan_zones: + findings.append( + build_finding( + f"firewall-wan-forwarding-{forwarding['id']}", + "Forwarding from WAN to an internal zone is enabled", + "high", + AREA, + "forwarding", + "Traffic can be forwarded from a WAN-like zone to an internal destination zone.", + forwarding, + "Remove broad WAN-to-internal forwardings and replace them with tightly scoped DNAT or proxy rules if required.", + nist=("AC-4", "SC-7", "CM-7"), + acn=("network_segmentation", "risk_management"), + ) + ) + + for redirect in inventory["redirects"]: + if not redirect.get("enabled", True): + continue + src = str(redirect.get("src")) + if src not in wan_zones and src.lower() not in WAN_NAMES: + continue + ports = redirect.get("src_dport", []) + severity = "high" if _port_is_admin(ports) or not ports else "medium" + findings.append( + build_finding( + f"firewall-public-redirect-{redirect['id']}", + "Public port-forward rule is enabled", + severity, + AREA, + "redirect", + "A DNAT/redirect rule exposes an internal service from a WAN-like source zone.", + redirect, + "Confirm business need, restrict source addresses, prefer VPN access, and remove unused public port-forwards.", + nist=("AC-4", "CM-7", "SC-7"), + acn=("network_segmentation", "risk_management"), + ) + ) + + for service in inventory["exposed_services"]: + if service.get("type") == "rule" and service.get("https_only"): + # Port 443 (HTTPS) is commonly used for proxy pass to legitimate services. + # This is advisory: it is not a critical risk but the admin UI should + # ideally not be reachable on the same public HTTPS port. + findings.append( + build_finding( + f"firewall-https-wan-access-{service['id']}", + "HTTPS port 443 is accessible from WAN", + "info", + AREA, + "rule", + "Port 443 is allowed from a WAN zone. If this routes to a reverse-proxy or legitimate web service, " + "it is not a risk in itself. However, if the administrator UI is reachable on port 443 from WAN, " + "consider restricting access to a VPN or a non-standard port.", + service, + "If the admin UI is on port 443, move it to a non-standard port or restrict WAN access via VPN.", + nist=("AC-17", "CM-7", "SC-7"), + acn=("network_segmentation", "identity_access_governance"), + ) + ) + elif service.get("type") == "rule" and service.get("admin_port"): + findings.append( + build_finding( + f"firewall-admin-service-exposed-{service['id']}", + "Administrative service port is allowed from WAN", + "critical", + AREA, + "rule", + "A firewall ACCEPT rule exposes a common management port from a WAN-like zone.", + service, + "Block WAN access to management ports and require access through a VPN or trusted management network.", + nist=("AC-4", "AC-17", "CM-7", "SC-7"), + acn=("network_segmentation", "identity_access_governance"), + ) + ) + elif service.get("type") == "rule" and not service.get("ports"): + findings.append( + build_finding( + f"firewall-broad-accept-{service['id']}", + "Broad WAN ACCEPT rule has no destination port scope", + "high", + AREA, + "rule", + "A WAN-facing ACCEPT rule lacks destination port scoping.", + service, + "Restrict the rule to required protocols, destination ports, and source addresses or remove it.", + nist=("AC-4", "CM-7", "SC-7"), + acn=("network_segmentation", "risk_management"), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_firewall = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/identity.py b/packages/ns-audit/files/ns_audit/analyzers/identity.py new file mode 100644 index 000000000..6b6ab5bfe --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/identity.py @@ -0,0 +1,358 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Identity, account, and administrative access analyzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + command_json, + contains_indicator, + get_file, + is_enabled_section, + json_safe, + option_value, + parse_group, + parse_passwd, + section_name, + section_type, + string_list, + to_bool, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + command_json, + contains_indicator, + get_file, + is_enabled_section, + json_safe, + option_value, + parse_group, + parse_passwd, + section_name, + section_type, + string_list, + to_bool, + uci_sections, + ) + +AREA = "identity" +NON_INTERACTIVE_SHELLS = {"/bin/false", "/sbin/nologin", "/usr/sbin/nologin", "nologin"} +MFA_INDICATORS = ("totp", "otp", "mfa", "2fa", "two_factor", "two-factor", "webauthn") + + +def _platform(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + board = command_json( + raw_snapshot, ("ubus call system board", "system board", "board") + ) + info = command_json(raw_snapshot, ("ubus call system info", "system info")) + system = ( + raw_snapshot.get("system") + if isinstance(raw_snapshot.get("system"), Mapping) + else {} + ) + if not board and isinstance(system, Mapping): + board = system.get("board") or {} + if not info and isinstance(system, Mapping): + info = system.get("info") or {} + return json_safe( + { + "hostname": (board or {}).get("hostname") or (system or {}).get("hostname"), + "model": (board or {}).get("model"), + "board_name": (board or {}).get("board_name"), + "release": (board or {}).get("release"), + "kernel": (board or {}).get("kernel"), + "uptime": (info or {}).get("uptime"), + "local_time": (info or {}).get("localtime"), + } + ) + + +def _accounts(raw_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + accounts = parse_passwd(get_file(raw_snapshot, "/etc/passwd")) + raw_accounts = raw_snapshot.get("accounts") + if not accounts and isinstance(raw_accounts, list): + accounts = [ + dict(account) for account in raw_accounts if isinstance(account, Mapping) + ] + groups = parse_group(get_file(raw_snapshot, "/etc/group")) + groups_by_gid = {str(group.get("gid")): name for name, group in groups.items()} + memberships: dict[str, list[str]] = { + account.get("username", ""): [] for account in accounts + } + for group_name, group in groups.items(): + for member in group.get("members", []): + memberships.setdefault(member, []).append(group_name) + normalized = [] + for account in accounts: + username = str(account.get("username", "")) + primary_group = groups_by_gid.get(str(account.get("gid"))) + account_groups = sorted( + set( + ([primary_group] if primary_group else []) + + memberships.get(username, []) + ) + ) + normalized.append( + { + "username": username, + "uid": account.get("uid"), + "gid": account.get("gid"), + "groups": account_groups, + "home": account.get("home"), + "shell": account.get("shell"), + "interactive_shell": account.get( + "interactive_shell", + account.get("shell") not in NON_INTERACTIVE_SHELLS, + ), + "is_superuser": account.get("uid") == 0, + } + ) + return json_safe(normalized) + + +def _dropbear(raw_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + instances = [] + for index, section in enumerate(uci_sections(raw_snapshot, "dropbear")): + if section_type(section) and section_type(section) != "dropbear": + continue + password_auth = to_bool( + option_value(section, ("PasswordAuth", "password_auth"), "on"), default=True + ) + root_password_auth = to_bool( + option_value(section, ("RootPasswordAuth", "root_password_auth"), "on"), + default=True, + ) + interface = string_list(option_value(section, ("Interface", "interface"), [])) + ports = string_list(option_value(section, ("Port", "port"), "22")) + instances.append( + { + "id": section_name(section) or f"dropbear-{index}", + "enabled": is_enabled_section(section, default=True), + "ports": ports or ["22"], + "interfaces": interface, + "password_auth": password_auth, + "root_password_auth": root_password_auth, + "gateway_ports": to_bool( + option_value(section, ("GatewayPorts", "gateway_ports")), + default=False, + ), + } + ) + return json_safe(instances) + + +def _configured_admins(raw_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + sections = uci_sections(raw_snapshot, "users") + uci_sections(raw_snapshot, "rpcd") + admins = [] + for section in sections: + safe_section = {} + for key, value in section.items(): + if any( + token in str(key).lower() + for token in ("password", "passwd", "secret", "token", "hash") + ): + safe_section[f"{key}_present"] = bool(value) + elif str(key).startswith(".") or key in { + "username", + "user", + "name", + "role", + "roles", + "groups", + "acl", + }: + safe_section[str(key)] = value + admins.append(safe_section) + return json_safe(admins) + + +def _non_root_rpcd_admins(raw_snapshot: Mapping[str, Any]) -> list[str]: + """Return usernames of non-root, non-controller RPCD login entries.""" + result = [] + for section in uci_sections(raw_snapshot, "rpcd"): + if section_type(section) != "login": + continue + username = str(option_value(section, "username", "") or "").strip() + if username and username not in {"root", ""}: + result.append(username) + return result + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Return identity inventory, findings, summary, and compliance placeholders.""" + accounts = _accounts(raw_snapshot) + dropbear = _dropbear(raw_snapshot) + configured_admins = _configured_admins(raw_snapshot) + non_root_admins = _non_root_rpcd_admins(raw_snapshot) + mfa_detected = contains_indicator( + {"users": configured_admins, "raw": raw_snapshot.get("mfa")}, MFA_INDICATORS + ) or contains_indicator(raw_snapshot.get("authentication", {}), MFA_INDICATORS) + + # Default password check (result stored as boolean only — no hash persisted) + security_checks = raw_snapshot.get("security_checks") or {} + default_pw_check = security_checks.get("default_pw_check") or {} + default_password_detected = bool(default_pw_check.get("is_default")) + default_password_checked = bool(default_pw_check.get("checked")) + + inventory = { + "platform": _platform(raw_snapshot), + "local_accounts": accounts, + "configured_admins": configured_admins, + "non_root_admins": non_root_admins, + "dropbear": dropbear, + "mfa_detected": mfa_detected, + "default_password_detected": default_password_detected, + "default_password_checked": default_password_checked, + } + findings = [] + + uid0_accounts = [account for account in accounts if account.get("uid") == 0] + extra_uid0 = [ + account for account in uid0_accounts if account.get("username") != "root" + ] + if extra_uid0: + findings.append( + build_finding( + "identity-extra-uid0-accounts", + "Additional UID 0 accounts detected", + "critical", + AREA, + "local_accounts", + "Accounts other than root have UID 0 and therefore full administrative privileges.", + {"accounts": [account.get("username") for account in extra_uid0]}, + "Remove unnecessary UID 0 accounts or assign them unique non-privileged UIDs with explicit sudo/role access.", + nist=("AC-2", "AC-6", "IA-2"), + acn=("identity_access_governance", "risk_management"), + ) + ) + + interactive_root = [ + account for account in uid0_accounts if account.get("interactive_shell") + ] + if interactive_root and not non_root_admins: + findings.append( + build_finding( + "identity-root-only-administration", + "Only the root account is used for administration", + "medium", + AREA, + "local_accounts", + "No named administrative users were found in RPCD login configuration. " + "Creating dedicated admin accounts follows the principle of least privilege and improves accountability.", + {"root_shells": [account.get("shell") for account in interactive_root]}, + "Create named administrator accounts with least-privilege roles and keep root access for emergency use only.", + nist=("AC-2", "AC-6", "IA-2"), + acn=("identity_access_governance",), + ) + ) + + # Default password check + if default_password_detected: + findings.append( + build_finding( + "identity-default-password", + "Root account uses the factory-default password", + "critical", + AREA, + "local_accounts", + "The root account password matches the factory default (Nethesis,1234). " + "This is a critical credential risk and must be changed immediately.", + {"default_password_checked": default_password_checked}, + "Change the root password to a strong, unique passphrase immediately.", + nist=("IA-5", "AC-2", "AC-6"), + acn=("identity_access_governance", "risk_management"), + ) + ) + + for instance in dropbear: + if not instance.get("enabled", True): + continue + if instance.get("password_auth"): + findings.append( + build_finding( + f"identity-dropbear-password-auth-{instance['id']}", + "SSH password authentication is enabled", + "high", + AREA, + "dropbear", + "Password-based SSH access increases exposure to brute-force and credential reuse attacks.", + { + "instance": instance["id"], + "ports": instance.get("ports"), + "interfaces": instance.get("interfaces"), + }, + "Disable SSH password authentication and require public-key or centrally governed administrative access.", + nist=("IA-2", "IA-5", "AC-17"), + acn=("identity_access_governance", "secure_communications"), + ) + ) + if instance.get("root_password_auth"): + findings.append( + build_finding( + f"identity-dropbear-root-password-{instance['id']}", + "Root SSH password login is enabled", + "high", + AREA, + "dropbear", + "Root password login exposes the most privileged account directly on SSH.", + {"instance": instance["id"], "ports": instance.get("ports")}, + "Set RootPasswordAuth to off and use named administrators with key-based authentication.", + nist=("AC-2", "AC-6", "IA-2", "IA-5"), + acn=("identity_access_governance",), + ) + ) + if not instance.get("interfaces") or any( + str(item).lower() in {"wan", "*", "0.0.0.0"} + for item in instance.get("interfaces", []) + ): + findings.append( + build_finding( + f"identity-dropbear-broad-listener-{instance['id']}", + "SSH listener is not restricted to a management interface", + "medium", + AREA, + "dropbear", + "The Dropbear listener has no explicit management interface restriction in UCI.", + { + "instance": instance["id"], + "interfaces": instance.get("interfaces"), + }, + "Bind SSH to a trusted management interface and enforce firewall rules that block WAN access.", + nist=("AC-17", "CM-7", "SC-7"), + acn=("identity_access_governance", "network_segmentation"), + ) + ) + + if not mfa_detected: + findings.append( + build_finding( + "identity-mfa-not-detected", + "Multi-factor authentication indicator not detected", + "medium", + AREA, + "administrative_access", + "The sanitized snapshot does not show OTP, TOTP, MFA, or WebAuthn indicators for administrative access.", + {"configured_admins": len(configured_admins)}, + "Enable MFA for administrator logins where supported and document compensating controls for emergency accounts.", + nist=("IA-2", "IA-5"), + acn=("identity_access_governance", "risk_management"), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_identity = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/ips.py b/packages/ns-audit/files/ns_audit/analyzers/ips.py new file mode 100644 index 000000000..cd2cb0aa1 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/ips.py @@ -0,0 +1,241 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""IPS/IDS engine status and coverage analyzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + contains_indicator, + file_map, + is_enabled_section, + option_value, + service_state, + string_list, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + contains_indicator, + file_map, + is_enabled_section, + option_value, + service_state, + string_list, + uci_sections, + ) + +AREA = "ips" +RULE_EVIDENCE_TERMS = ( + "rules", + "community.rules", + "emerging.rules", + "last_update", + "download", + "oinkcode", +) + + +def _snort_engine(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + sections = uci_sections(raw_snapshot, "snort") + uci_sections( + raw_snapshot, "snort3" + ) + main = sections[0] if sections else {} + service = service_state(raw_snapshot, ("snort", "snort3")) + enabled = any(is_enabled_section(section, default=False) for section in sections) + mode = str(option_value(main, "mode", "")).lower() + interfaces = [] + for section in sections: + interfaces.extend( + string_list( + option_value(section, ("interface", "interfaces", "ifname"), []) + ) + ) + rule_evidence = contains_indicator( + {"sections": sections, "files": file_map(raw_snapshot)}, RULE_EVIDENCE_TERMS + ) + return { + "name": "snort", + "present": bool(sections) or service.get("present"), + "enabled": enabled, + "service_running": service.get("running"), + "service_enabled": service.get("enabled"), + "mode": mode or "unknown", + "action": str(option_value(main, "action", "")), + "method": str(option_value(main, "method", "")), + "interfaces": sorted(set(interfaces)), + "logging_enabled": bool(option_value(main, "logging", "")), + "rule_update_evidence": rule_evidence, + } + + +def _suricata_engine(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + sections = uci_sections(raw_snapshot, "suricata") + main = sections[0] if sections else {} + service = service_state(raw_snapshot, ("suricata",)) + enabled = any(is_enabled_section(section, default=False) for section in sections) + interfaces = [] + for section in sections: + interfaces.extend( + string_list( + option_value(section, ("interface", "interfaces", "ifname"), []) + ) + ) + rule_evidence = contains_indicator( + {"sections": sections, "files": file_map(raw_snapshot)}, RULE_EVIDENCE_TERMS + ) + return { + "name": "suricata", + "present": bool(sections) or service.get("present"), + "enabled": enabled, + "service_running": service.get("running"), + "service_enabled": service.get("enabled"), + "mode": str(option_value(main, "mode", "unknown")).lower(), + "interfaces": sorted(set(interfaces)), + "rule_update_evidence": rule_evidence, + } + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + engines = [_snort_engine(raw_snapshot), _suricata_engine(raw_snapshot)] + active_engines = [ + engine + for engine in engines + if engine.get("present") + or engine.get("enabled") + or engine.get("service_running") + ] + inventory = { + "engines": active_engines or [engines[0]], + "active_engine_count": len(active_engines), + "running_engine_count": len( + [engine for engine in engines if engine.get("service_running")] + ), + } + findings = [] + + if not active_engines: + findings.append( + build_finding( + "ips-no-engine-evidence", + "No IPS/IDS engine evidence found", + "high", + AREA, + "ips_ids", + "The sanitized snapshot does not show Snort configuration or service status.", + {}, + "Enable and tune Snort on protected interfaces.", + nist=("RA-5", "SI-3", "SI-4", "CM-6"), + acn=("threat_detection", "risk_management"), + ) + ) + elif inventory["running_engine_count"] == 0: + # Engines are present/configured but none is actually running + findings.append( + build_finding( + "ips-no-engine-running", + "IPS/IDS engine is configured but none is running", + "high", + AREA, + "ips_ids", + "At least one IPS/IDS engine was detected in the configuration, but no engine process is currently active.", + {"active_engine_count": inventory["active_engine_count"], "running_engine_count": 0}, + "Start the IPS/IDS service, check logs for startup errors, and verify interface binding.", + nist=("SI-3", "SI-4", "CM-6"), + acn=("threat_detection", "risk_management"), + ) + ) + + for engine in active_engines: + name = engine.get("name") + if engine.get("enabled") and not engine.get("service_running"): + findings.append( + build_finding( + f"ips-{name}-not-running", + f"{name} is enabled but not running", + "high", + AREA, + name, + "The IPS/IDS configuration is enabled but procd service status does not show a running instance.", + engine, + "Start the service, inspect its logs, and resolve configuration or rule loading errors.", + nist=("SI-3", "SI-4", "CM-6"), + acn=("threat_detection", "risk_management"), + ) + ) + if engine.get("service_running") and not engine.get("enabled"): + findings.append( + build_finding( + f"ips-{name}-running-without-enabled-config", + f"{name} service is running but enabled configuration was not found", + "medium", + AREA, + name, + "Runtime service status and UCI enablement evidence do not match.", + engine, + "Review UCI configuration and service startup settings so audit evidence matches runtime state.", + nist=("CM-6", "SI-4"), + acn=("threat_detection", "risk_management"), + ) + ) + if engine.get("enabled") and not engine.get("interfaces"): + findings.append( + build_finding( + f"ips-{name}-no-interfaces", + f"{name} has no protected interfaces in snapshot", + "medium", + AREA, + name, + "The IPS/IDS engine is enabled but no inspected interfaces were found in configuration.", + engine, + "Assign the engine to the ingress/egress interfaces that require detection or prevention coverage.", + nist=("SI-3", "SI-4", "SC-7"), + acn=("threat_detection", "network_segmentation"), + ) + ) + if engine.get("enabled") and not engine.get("rule_update_evidence"): + findings.append( + build_finding( + f"ips-{name}-no-rule-update-evidence", + f"{name} rule update evidence not found", + "medium", + AREA, + name, + "No local evidence of IPS/IDS rules or update metadata was found in the sanitized snapshot.", + engine, + "Download current rules, schedule rule updates, and retain update status in audit evidence.", + nist=("RA-5", "SI-3", "SI-4"), + acn=("threat_detection", "risk_management"), + ) + ) + if name == "snort" and engine.get("enabled") and engine.get("mode") == "ids": + findings.append( + build_finding( + "ips-snort-ids-only", + "Snort is configured in IDS mode only", + "medium", + AREA, + "snort", + "IDS mode detects threats but does not block malicious traffic inline.", + engine, + "Use IPS mode for inline protection where supported, or document monitoring-only compensating controls.", + nist=("SI-3", "SI-4", "SC-7"), + acn=("threat_detection", "network_segmentation"), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_ips = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/logging.py b/packages/ns-audit/files/ns_audit/analyzers/logging.py new file mode 100644 index 000000000..96bdbe411 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/logging.py @@ -0,0 +1,465 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Syslog, log retention, and log protection analyzer.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + file_map, + file_metadata, + files_matching, + get_file, + option_value, + section_type, + string_list, + to_bool, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + file_map, + file_metadata, + files_matching, + get_file, + option_value, + section_type, + string_list, + to_bool, + uci_sections, + ) + +AREA = "logging" +MIN_LOG_SIZE_KIB = 128 + + +def _int_value(value: Any, default: int = 0) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def _mode_is_world_writable(metadata: Mapping[str, Any]) -> bool: + mode = metadata.get("mode") or metadata.get("permissions") + if mode is None: + return False + text = str(mode) + try: + numeric = int(text, 8) if not text.startswith("0o") else int(text, 8) + except ValueError: + match = re.search(r"([0-7]{3,4})", text) + numeric = int(match.group(1), 8) if match else 0 + return bool(numeric & 0o002) + + +def _system_logging(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + system_sections = [ + section + for section in uci_sections(raw_snapshot, "system") + if section_type(section) in {"system", ""} + ] + system = system_sections[0] if system_sections else {} + log_ip = str(option_value(system, "log_ip", "")) + log_port = str(option_value(system, "log_port", "")) + log_proto = str(option_value(system, "log_proto", "udp") or "udp").lower() + ntp_sections = [ + section + for section in uci_sections(raw_snapshot, "system") + if section_type(section) == "timeserver" + ] + ntp_enabled = any( + to_bool(option_value(section, "enabled", "1"), default=True) + for section in ntp_sections + ) + ntp_servers = [] + for section in ntp_sections: + ntp_servers.extend(string_list(option_value(section, "server", []))) + return { + "log_size_kib": _int_value(option_value(system, "log_size", 0)), + "log_ip": log_ip, + "log_port": log_port, + "log_proto": log_proto, + "remote_syslog_configured": bool(log_ip), + "hostname": str(option_value(system, "hostname", "")), + "timezone": str(option_value(system, "timezone", "")), + "ntp_enabled": ntp_enabled, + "ntp_servers_configured": bool(ntp_servers), + } + + +def _rsyslog(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + sections = uci_sections(raw_snapshot, "rsyslog") + config_texts = [get_file(raw_snapshot, "/etc/rsyslog.conf")] + config_texts.extend( + str(value.get("content", "")) if isinstance(value, Mapping) else str(value) + for value in files_matching(raw_snapshot, "rsyslog").values() + ) + remotes = [] + tls_indicator = False + for section in sections: + target = option_value(section, ("target", "server", "host", "remote"), "") + if target: + remotes.append( + { + "target_present": True, + "proto": str(option_value(section, "proto", "")), + } + ) + tls_indicator = tls_indicator or bool( + option_value(section, ("tls", "streamdriver", "ca_file", "cert_file")) + ) + for text in config_texts: + for line in str(text).splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if ( + "@@" in stripped + or "omfwd" in stripped + or re.search(r"(^|\s)@[^@]", stripped) + ): + remotes.append( + { + "target_present": True, + "source": "rsyslog.conf", + "tcp": "@@" in stripped, + } + ) + if any( + token in stripped.lower() + for token in ("gtls", "streamdriver", "x509", "tls") + ): + tls_indicator = True + return { + "sections": len(sections), + "remote_targets": remotes, + "tls_indicator": tls_indicator, + } + + +def _storage_status(raw_snapshot: Mapping[str, Any]) -> str: + status = raw_snapshot.get("storage_status") + if not isinstance(status, Mapping): + return "" + stdout = str(status.get("stdout", "")).strip().lower() + if stdout in {"ok", "error", "not_configured"}: + return stdout + return "" + + +def _log_files(raw_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + seen: set[str] = set() + result = [] + + def _add_entry( + path_text: str, content_present: bool, metadata: Mapping[str, Any] + ) -> None: + if path_text in seen: + return + seen.add(path_text) + result.append( + { + "path": path_text, + "content_present": content_present, + "persistent": path_text.startswith("/mnt/data/"), + "mode": metadata.get("mode") or metadata.get("permissions"), + "world_writable": _mode_is_world_writable(metadata), + } + ) + + # Config files / logrotate files (stored in raw_snapshot["files"]) + for path, value in file_map(raw_snapshot).items(): + path_text = str(path) + if ( + "/log/" not in path_text + and not path_text.endswith("/messages") + and "logrotate" not in path_text + ): + continue + _add_entry(path_text, bool(value), file_metadata(raw_snapshot, path_text)) + + # Actual log files (stored in raw_snapshot["logs"]["files"] as a list) + logs = raw_snapshot.get("logs") + if isinstance(logs, Mapping): + log_file_list = logs.get("files") + if isinstance(log_file_list, (list, tuple)): + for entry in log_file_list: + if not isinstance(entry, Mapping): + continue + path_text = str(entry.get("path", "")) + if not path_text: + continue + if ( + "/log/" not in path_text + and not path_text.endswith("/messages") + and "logrotate" not in path_text + ): + continue + content_present = bool(entry.get("readable") and entry.get("content")) + _add_entry(path_text, content_present, {}) + + return result + + +def _controller_connection(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Check if the device is registered with a NethSecurity controller.""" + for section in uci_sections(raw_snapshot, "ns-plug"): + server = str(option_value(section, "server", "") or "").strip() + if server: + return {"connected": True, "server_present": True} + return {"connected": False, "server_present": False} + + +def _logging_assessment( + storage_mounted: bool, + persistent_log_files: list[dict[str, Any]], + remote_targets: list[dict[str, Any]], + controller: Mapping[str, Any], +) -> dict[str, Any]: + has_persistent_storage = storage_mounted or bool(persistent_log_files) + controller_connected = bool(controller.get("connected")) + remote_forwarding = bool(remote_targets) + + if has_persistent_storage and controller_connected: + overall = "perfect" + detail = ( + "Persistent log storage is enabled and the firewall is also forwarding " + "logs to the controller." + ) + elif has_persistent_storage and remote_forwarding: + overall = "excellent" + detail = ( + "Persistent log storage is enabled and remote forwarding is configured." + ) + elif has_persistent_storage: + overall = "very good" + detail = "Persistent log storage is enabled, so audit evidence survives reboot." + elif controller_connected or remote_forwarding: + overall = "good" + detail = ( + "Remote forwarding is configured, but persistent local storage was not detected." + ) + else: + overall = "baseline" + detail = "Only local volatile logging evidence was detected." + + forwarding = ( + "controller forwarding enabled" + if controller_connected + else "remote forwarding configured" + if remote_forwarding + else "local only" + ) + storage = ( + "persistent storage mounted" + if has_persistent_storage + else "volatile ring buffer only" + ) + return { + "overall_posture": overall, + "storage_posture": storage, + "forwarding_posture": forwarding, + "controller_connected": controller_connected, + "persistent_storage": has_persistent_storage, + "summary": detail, + } + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + system_logging = _system_logging(raw_snapshot) + rsyslog = _rsyslog(raw_snapshot) + log_files = _log_files(raw_snapshot) + logrotate_files = sorted(files_matching(raw_snapshot, "logrotate").keys()) + storage_status = _storage_status(raw_snapshot) + storage_mounted = storage_status == "ok" + controller = _controller_connection(raw_snapshot) + remote_targets = [] + if system_logging["remote_syslog_configured"]: + remote_targets.append( + { + "source": "system", + "target_present": True, + "proto": system_logging.get("log_proto"), + "port": system_logging.get("log_port"), + } + ) + remote_targets.extend(rsyslog["remote_targets"]) + # Controller connection means logs are automatically forwarded to the remote controller + if controller["connected"]: + remote_targets.append({"source": "controller", "target_present": True, "proto": "https"}) + persistent_log_files = [item for item in log_files if item.get("persistent")] + assessment = _logging_assessment( + storage_mounted, persistent_log_files, remote_targets, controller + ) + inventory = { + "system": system_logging, + "rsyslog": rsyslog, + "remote_targets": remote_targets, + "logrotate_files": logrotate_files, + "log_files": log_files, + "storage_status": storage_status or "unknown", + "persistent_log_files": persistent_log_files, + "controller": controller, + "assessment": assessment, + } + findings = [] + + if not remote_targets: + findings.append( + build_finding( + "logging-no-remote-syslog", + "Remote syslog/SIEM forwarding not detected", + "medium", + AREA, + "syslog", + "The snapshot does not show log forwarding to a remote syslog or SIEM destination.", + { + "system_log_ip_present": bool(system_logging.get("log_ip")), + "rsyslog_targets": len(rsyslog["remote_targets"]), + "controller_connected": controller["connected"], + }, + "Forward security and system logs to a protected remote collector or SIEM with documented retention. " + "Connecting to a NethSecurity controller automatically forwards logs.", + nist=("AU-2", "AU-6", "AU-9", "AU-11", "SI-4"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + insecure_remote = [ + target + for target in remote_targets + if str(target.get("proto", "udp")).lower() == "udp" + and not rsyslog["tls_indicator"] + ] + if insecure_remote: + findings.append( + build_finding( + "logging-remote-syslog-udp", + "Remote logging appears to use UDP without TLS evidence", + "medium", + AREA, + "syslog", + "UDP syslog can lose messages and does not provide transport confidentiality or integrity.", + {"targets": insecure_remote}, + "Prefer TCP/TLS syslog or another authenticated log transport to the collector/SIEM.", + nist=("AU-9", "SC-8", "SC-13"), + acn=("logging_monitoring", "secure_communications"), + ) + ) + + if ( + system_logging.get("log_size_kib", 0) + and system_logging["log_size_kib"] < MIN_LOG_SIZE_KIB + and not (storage_mounted or inventory["persistent_log_files"]) + ): + findings.append( + build_finding( + "logging-small-ring-buffer", + "Local log buffer is small", + "medium", + AREA, + "system_log", + "The OpenWrt system log buffer may roll over quickly when persistent storage is not mounted.", + { + "storage_status": inventory["storage_status"], + "storage_mounted": storage_mounted, + "persistent_log_files": [ + item.get("path") for item in inventory["persistent_log_files"] + ], + }, + "Mount persistent storage and/or increase log_size so audit evidence survives rollover.", + nist=("AU-2", "AU-11", "SI-4"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + if not inventory["persistent_log_files"]: + findings.append( + build_finding( + "logging-no-persistent-log-evidence", + "Persistent log storage evidence not detected", + "low", + AREA, + "log_retention", + "No log files under /mnt/data/log were present in the sanitized snapshot.", + {"log_files": [item.get("path") for item in log_files]}, + "Use persistent storage or remote forwarding so evidence survives reboot and volatile log rotation.", + nist=("AU-9", "AU-11"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + if not logrotate_files: + findings.append( + build_finding( + "logging-no-logrotate-evidence", + "Log rotation evidence not detected", + "medium", + AREA, + "logrotate", + "No logrotate configuration files were found in the sanitized snapshot.", + {}, + "Configure log rotation for local audit logs and verify retention aligns with policy.", + nist=("AU-9", "AU-11", "CM-6"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + writable_logs = [item for item in log_files if item.get("world_writable")] + if writable_logs: + findings.append( + build_finding( + "logging-world-writable-logs", + "World-writable log files detected", + "high", + AREA, + "log_files", + "At least one local log file has world-writable permissions in metadata evidence.", + {"files": writable_logs}, + "Restrict log file permissions to root or the dedicated logging service account.", + nist=("AU-9", "AC-6"), + acn=("logging_monitoring", "identity_access_governance"), + ) + ) + + if not ( + system_logging.get("ntp_enabled") + and system_logging.get("ntp_servers_configured") + ): + findings.append( + build_finding( + "logging-time-sync-not-confirmed", + "Time synchronization evidence is incomplete", + "medium", + AREA, + "time_sync", + "Reliable timestamps require configured and enabled NTP/time synchronization.", + { + "ntp_enabled": system_logging.get("ntp_enabled"), + "ntp_servers_configured": system_logging.get( + "ntp_servers_configured" + ), + }, + "Enable NTP and configure trusted time servers so audit records have reliable timestamps.", + nist=("AU-2", "AU-6"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_logging = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/telemetry.py b/packages/ns-audit/files/ns_audit/analyzers/telemetry.py new file mode 100644 index 000000000..6332d0b08 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/telemetry.py @@ -0,0 +1,274 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Security event and telemetry indicator analyzer.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + contains_indicator, + get_file, + json_safe, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + contains_indicator, + get_file, + json_safe, + ) + +AREA = "telemetry" +MAX_LOG_LINES = 5000 +PATTERNS = { + "auth_failures": re.compile( + r"failed password|authentication failure|login failed|invalid user|bad password", + re.I, + ), + "ssh_successes": re.compile( + r"accepted (password|publickey)|login succeeded|session opened", re.I + ), + "firewall_drops": re.compile( + r"\b(drop|reject)\b.*\b(in=|src=)|\b(in=|src=).*\b(drop|reject)\b", re.I + ), + "ips_alerts": re.compile(r"snort|suricata|\[\*\*\]|\bids\b|\bips\b", re.I), + "vpn_failures": re.compile( + r"auth_failed|tls error|verify error|openvpn.*fail|wireguard.*fail", re.I + ), + "config_changes": re.compile( + r"uci|ns\.commit|configuration changed|config.*commit", re.I + ), + "reboots": re.compile(r"syslogd started|kernel:|boot", re.I), +} +TELEMETRY_TERMS = ( + "netify", + "telegraf", + "prometheus", + "loki", + "promtail", + "grafana", + "monitoring", +) + + +def _log_summary(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + logs = ( + raw_snapshot.get("logs") + if isinstance(raw_snapshot.get("logs"), Mapping) + else {} + ) + summary = ( + logs.get("summary") + if isinstance(logs, Mapping) and isinstance(logs.get("summary"), Mapping) + else {} + ) + counters = ( + {key: int(value) for key, value in summary.items() if str(value).isdigit()} + if isinstance(summary, Mapping) + else {} + ) + return counters + + +def _entry_lines(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return value.splitlines() + if isinstance(value, Mapping): + return [" ".join(f"{key}={item}" for key, item in value.items())] + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + lines = [] + for item in value: + lines.extend(_entry_lines(item)) + return lines + return [str(value)] + + +def _log_lines(raw_snapshot: Mapping[str, Any]) -> list[str]: + logs = ( + raw_snapshot.get("logs") + if isinstance(raw_snapshot.get("logs"), Mapping) + else {} + ) + lines: list[str] = [] + + # Collect from logread stdout + if isinstance(logs, Mapping): + logread = logs.get("logread") + if isinstance(logread, Mapping): + stdout = logread.get("stdout") + if isinstance(stdout, str) and stdout: + lines.extend(stdout.splitlines()) + + # Collect from log file evidence list (raw_snapshot["logs"]["files"]) + if isinstance(logs, Mapping): + log_files = logs.get("files") + if isinstance(log_files, (list, tuple)): + for entry in log_files: + if isinstance(entry, Mapping): + content = entry.get("content") + if isinstance(content, str) and content: + lines.extend(content.splitlines()) + + # Fallback: check top-level files dict (in case logs are stored there) + for path in ("/var/log/messages", "/mnt/data/log/messages"): + extra = get_file(raw_snapshot, path) + if extra: + lines.extend(extra.splitlines()) + + return lines[-MAX_LOG_LINES:] + + +def _count_patterns(lines: Sequence[str]) -> dict[str, int]: + counts = {name: 0 for name in PATTERNS} + for line in lines: + for name, pattern in PATTERNS.items(): + if pattern.search(line): + counts[name] += 1 + return counts + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + lines = _log_lines(raw_snapshot) + counters = _count_patterns(lines) + for key, value in _log_summary(raw_snapshot).items(): + counters[key] = max(counters.get(key, 0), value) + telemetry_detected = contains_indicator(raw_snapshot, TELEMETRY_TERMS) + inventory = { + "log_lines_analyzed": len(lines), + "security_event_counters": counters, + "telemetry_indicators_detected": telemetry_detected, + "telemetry_terms": [ + term + for term in TELEMETRY_TERMS + if contains_indicator(raw_snapshot, (term,)) + ], + } + findings = [] + + if not lines and not any(counters.values()): + findings.append( + build_finding( + "telemetry-no-log-evidence", + "No log or telemetry evidence was available for analysis", + "medium", + AREA, + "logs", + "The sanitized snapshot did not include log lines or precomputed security counters.", + {}, + "Collect bounded log samples or summarized counters from logread and persistent log files for audit evidence.", + nist=("AU-2", "AU-6", "SI-4"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + auth_failures = counters.get("auth_failures", 0) + if auth_failures >= 50: + severity = "high" + elif auth_failures >= 10: + severity = "medium" + else: + severity = "" + if severity: + findings.append( + build_finding( + "telemetry-auth-failure-spike", + "Repeated authentication failures found in logs", + severity, + AREA, + "authentication_logs", + "Log indicators show repeated failed authentication attempts during the sampled period.", + {"auth_failures": auth_failures}, + "Investigate source addresses, block abusive sources, and verify account lockout/MFA controls.", + nist=("AU-6", "IA-2", "SI-4"), + acn=( + "identity_access_governance", + "logging_monitoring", + "threat_detection", + ), + ) + ) + + vpn_failures = counters.get("vpn_failures", 0) + if vpn_failures >= 10: + findings.append( + build_finding( + "telemetry-vpn-failures", + "VPN failure indicators found in logs", + "medium", + AREA, + "vpn_logs", + "OpenVPN or WireGuard failure patterns were observed in the sampled logs.", + {"vpn_failures": vpn_failures}, + "Review VPN authentication and TLS errors, then correlate with user and source address activity.", + nist=("AU-6", "AC-17", "SI-4"), + acn=("secure_communications", "logging_monitoring", "threat_detection"), + ) + ) + + ips_alerts = counters.get("ips_alerts", 0) + if ips_alerts: + findings.append( + build_finding( + "telemetry-ips-alerts", + "IPS/IDS alert indicators found in logs", + "medium", + AREA, + "ips_logs", + "Snort/Suricata or generic IDS/IPS alert indicators were observed in the sampled logs.", + {"ips_alerts": ips_alerts}, + "Triage alerts, confirm rule freshness, and document incident handling or false-positive disposition.", + nist=("AU-6", "RA-5", "SI-3", "SI-4"), + acn=("threat_detection", "logging_monitoring", "risk_management"), + ) + ) + + firewall_drops = counters.get("firewall_drops", 0) + if firewall_drops >= 500: + findings.append( + build_finding( + "telemetry-high-firewall-drops", + "High firewall drop/reject volume found in logs", + "low", + AREA, + "firewall_logs", + "Firewall drop/reject indicators exceed the informational audit threshold.", + {"firewall_drops": firewall_drops}, + "Review whether the volume reflects expected internet noise or targeted scanning and tune alerting thresholds.", + nist=("AU-6", "SI-4"), + acn=("logging_monitoring", "threat_detection"), + ) + ) + + if not telemetry_detected: + findings.append( + build_finding( + "telemetry-monitoring-not-detected", + "Monitoring/telemetry integration indicator not detected", + "low", + AREA, + "telemetry", + "No local evidence of monitoring integrations such as Netify, Telegraf, Prometheus, Loki, or Promtail was found.", + {"terms_checked": TELEMETRY_TERMS}, + "Enable telemetry integrations appropriate for the deployment and document where operational metrics are reviewed.", + nist=("AU-6", "SI-4"), + acn=("logging_monitoring", "risk_management"), + ) + ) + + return build_result(AREA, json_safe(inventory), findings) + + +analyze_telemetry = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/updates.py b/packages/ns-audit/files/ns_audit/analyzers/updates.py new file mode 100644 index 000000000..7197b44b1 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/updates.py @@ -0,0 +1,193 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Firmware version currency and TLS certificate analyzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + option_value, + string_list, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + option_value, + string_list, + uci_sections, + ) + +AREA = "updates" + +_ACME_DIRS = ("/etc/acme", "/etc/ssl/acme", "/etc/ssl/certs") + + +def _update_info(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + security_checks = raw_snapshot.get("security_checks") or {} + return dict(security_checks.get("update") or {}) + + +def _certificate_info(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Check for Let's Encrypt (acme) or uploaded TLS certificates.""" + # Check ACME UCI config + acme_sections = uci_sections(raw_snapshot, "acme") + has_acme_config = bool(acme_sections) + has_acme_cert = False + domains: list[str] = [] + for section in acme_sections: + enabled = str(option_value(section, ("enabled", "enable"), "0")).strip() + if enabled == "1": + has_acme_cert = True + domains.extend(string_list(option_value(section, ("domains", "domain"), []))) + + # Check for certificate files in known paths via collected files dict + files = raw_snapshot.get("files") or {} + cert_paths = [ + p for p in files if any( + str(p).startswith(prefix) for prefix in _ACME_DIRS + ) and str(p).endswith((".crt", ".pem", ".cer", ".fullchain")) + ] + if cert_paths: + has_acme_cert = True + + return { + "acme_config_present": has_acme_config, + "acme_cert_active": has_acme_cert, + "domains": domains, + "cert_file_evidence": cert_paths, + } + + +def _subscription_info(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + security_checks = raw_snapshot.get("security_checks") or {} + return dict(security_checks.get("subscription") or {}) + + +def _automatic_update_info(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + security_checks = raw_snapshot.get("security_checks") or {} + return dict(security_checks.get("automatic_updates") or {}) + + +def _ha_status(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + for section in uci_sections(raw_snapshot, "keepalived"): + name = str(section.get(".name", "") or section.get("name", "")).strip() + if name == "globals": + enabled = str(option_value(section, "enabled", "0")).strip() + return {"configured": True, "enabled": enabled == "1"} + return {"configured": False, "enabled": False} + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + update = _update_info(raw_snapshot) + automatic_updates = _automatic_update_info(raw_snapshot) + cert = _certificate_info(raw_snapshot) + subscription = _subscription_info(raw_snapshot) + ha = _ha_status(raw_snapshot) + update_inventory = { + "version_check_completed": bool(update.get("checked")), + "up_to_date": update.get("up_to_date"), + "local_version": update.get("local_version"), + "latest_version": update.get("latest_version"), + "automatic_updates_checked": bool(automatic_updates.get("checked")), + "automatic_updates_enabled": automatic_updates.get("enabled"), + } + if update.get("error"): + update_inventory["version_check_error"] = update.get("error") + if automatic_updates.get("error"): + update_inventory["automatic_updates_error"] = automatic_updates.get("error") + + inventory = { + "update": update_inventory, + "ha": ha, + "subscription": subscription, + "certificate": cert, + } + findings = [] + + # Version currency check + if not update.get("checked"): + error = update.get("error", "unknown") + findings.append( + build_finding( + "updates-version-check-failed", + "Firmware version currency check could not be completed", + "info", + AREA, + "firmware", + f"The audit tool could not reach the NethSecurity update server to verify version currency. Error: {error}", + {"error": error}, + "Verify internet connectivity and re-run the audit to check if the firmware is up to date.", + nist=("CM-6", "RA-5", "SI-2"), + acn=("risk_management",), + ) + ) + elif update.get("up_to_date") is False: + local = update.get("local_version", "unknown") + latest = update.get("latest_version", "unknown") + findings.append( + build_finding( + "updates-firmware-outdated", + "Firmware is not up to date", + "high", + AREA, + "firmware", + f"The installed firmware version ({local}) is older than the latest stable release ({latest}). " + "Outdated firmware may contain known security vulnerabilities.", + {"local_version": local, "latest_version": latest}, + f"Update the firmware to version {latest} or later via the NethSecurity UI or CLI.", + nist=("CM-6", "RA-5", "SI-2"), + acn=("risk_management",), + ) + ) + + # TLS certificate check (advisory/positive) + if not cert.get("acme_cert_active") and not cert.get("cert_file_evidence"): + findings.append( + build_finding( + "updates-no-tls-cert", + "No Let's Encrypt or uploaded TLS certificate detected", + "info", + AREA, + "certificate", + "No ACME/Let's Encrypt configuration or certificate file was found. " + "The web UI may be using a self-signed certificate, which triggers browser warnings and reduces trust.", + {}, + "Request a Let's Encrypt certificate via the NethSecurity UI (System > Certificates) " + "or upload a valid TLS certificate for the admin interface.", + nist=("SC-8", "SC-13", "IA-5"), + acn=("secure_communications", "risk_management"), + ) + ) + + if subscription.get("available") and not subscription.get("active"): + findings.append( + build_finding( + "updates-no-subscription", + "No active NethSecurity subscription detected", + "info", + AREA, + "subscription", + "A NethSecurity subscription provides access to commercial threat feeds, enterprise support, " + "and automated security updates. No active subscription was found.", + {}, + "Consider activating a NethSecurity subscription for commercial support and threat intelligence.", + nist=("SI-2", "SI-3", "RA-5"), + acn=("risk_management",), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_updates = analyze diff --git a/packages/ns-audit/files/ns_audit/analyzers/vpn.py b/packages/ns-audit/files/ns_audit/analyzers/vpn.py new file mode 100644 index 000000000..843c2692f --- /dev/null +++ b/packages/ns-audit/files/ns_audit/analyzers/vpn.py @@ -0,0 +1,625 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""OpenVPN and WireGuard crypto posture analyzer.""" + +from __future__ import annotations + +import re +import time +from collections.abc import Mapping +from typing import Any + +try: + from ..models import ( + build_finding, + build_result, + command_json, + contains_indicator, + fingerprint, + get_file, + has_unredacted_secret, + is_enabled_section, + option_value, + section_name, + section_type, + string_list, + to_bool, + uci_sections, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + build_finding, + build_result, + command_json, + contains_indicator, + fingerprint, + get_file, + has_unredacted_secret, + is_enabled_section, + option_value, + section_name, + section_type, + string_list, + to_bool, + uci_sections, + ) + +AREA = "vpn" +WEAK_CIPHER_TOKENS = ("BF-CBC", "DES", "3DES", "RC2", "NULL", "MD5") +WEAK_AUTH_TOKENS = ("MD5", "SHA1") +MFA_INDICATORS = ("totp", "otp", "mfa", "2fa", "two_factor", "two-factor") +FULL_TUNNEL_ALLOWED_IPS = {"0.0.0.0/0", "::/0"} +STALE_HANDSHAKE_SECONDS = 7 * 24 * 60 * 60 + + +def _tls_version_number(value: Any) -> float | None: + match = re.search(r"(\d+(?:\.\d+)?)", str(value or "")) + return float(match.group(1)) if match else None + + +def _cipher_values(section: Mapping[str, Any]) -> list[str]: + values = [] + for key in ( + "data_ciphers", + "ncp_ciphers", + "tls_cipher", + "tls_ciphersuites", + "cipher", + ): + values.extend(string_list(option_value(section, key, []))) + return [value.upper() for value in values] + + +def _weak_cipher_values(values: list[str]) -> list[str]: + return sorted( + {value for value in values for token in WEAK_CIPHER_TOKENS if token in value} + ) + + +def _openvpn_instances(raw_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + instances = [] + for section in uci_sections(raw_snapshot, "openvpn"): + if section_type(section) and section_type(section) != "openvpn": + continue + ciphers = _cipher_values(section) + instance = { + "id": section_name(section), + "enabled": is_enabled_section(section, default=False), + "proto": str(option_value(section, "proto", "")), + "port": str(option_value(section, "port", "")), + "dev": str(option_value(section, "dev", "")), + "mode": str(option_value(section, "mode", "")), + "tls_version_min": str(option_value(section, "tls_version_min", "")), + "cipher_values": ciphers, + "weak_cipher_values": _weak_cipher_values(ciphers), + "auth_digest": str(option_value(section, "auth", "")).upper(), + "compression_enabled": any( + to_bool(option_value(section, key), default=False) + for key in ("comp_lzo", "compress", "allow_compression") + ), + "duplicate_cn": to_bool( + option_value(section, "duplicate_cn"), default=False + ), + "client_to_client": to_bool( + option_value(section, "client_to_client"), default=False + ), + "user_password_auth": bool( + option_value( + section, ("auth_user_pass_verify", "plugin", "script_security") + ) + ), + "mfa_indicator": contains_indicator(section, MFA_INDICATORS), + "secret_material_present": any( + bool(option_value(section, key)) + for key in ("key", "pkcs12", "secret", "tls_auth", "tls_crypt") + ), + "unredacted_secret_detected": has_unredacted_secret(section), + } + instances.append(instance) + return instances + + +def _wireguard_status(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + status = command_json(raw_snapshot, ("wg-json", "wg show", "wireguard")) + return status if isinstance(status, Mapping) else {} + + +def _handshake_age(value: Any) -> int | None: + if value in (None, "", 0, "0"): + return None + try: + number = int(float(str(value))) + except ValueError: + return None + if number > 1_000_000_000: + return max(0, int(time.time()) - number) + return number + + +def _status_peers( + status: Mapping[str, Any], interface_name: str +) -> list[dict[str, Any]]: + interface_status = ( + status.get(interface_name) if isinstance(status, Mapping) else None + ) + if not isinstance(interface_status, Mapping): + return [] + raw_peers = interface_status.get("peers", []) + if isinstance(raw_peers, Mapping): + iterator = raw_peers.items() + elif isinstance(raw_peers, list): + iterator = [(str(index), peer) for index, peer in enumerate(raw_peers)] + else: + return [] + peers = [] + for peer_id, peer_data in iterator: + if not isinstance(peer_data, Mapping): + continue + latest = ( + peer_data.get("latest_handshake") + or peer_data.get("latestHandshake") + or peer_data.get("last_handshake") + ) + peers.append( + { + "id": str(peer_id), + "public_key_fingerprint": fingerprint(peer_id), + "allowed_ips": string_list( + peer_data.get("allowed_ips") or peer_data.get("allowedIps") + ), + "endpoint_present": bool(peer_data.get("endpoint")), + "latest_handshake_age_seconds": _handshake_age(latest), + } + ) + return peers + + +def _wireguard_inventory(raw_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + network_sections = uci_sections(raw_snapshot, "network") + status = _wireguard_status(raw_snapshot) + interfaces = [] + for section in network_sections: + if ( + section_type(section) != "interface" + or str(option_value(section, "proto", "")).lower() != "wireguard" + ): + continue + name = section_name(section) + peers = [] + for peer in network_sections: + peer_type = section_type(peer) + if not peer_type.startswith("wireguard_"): + continue + peer_interface = peer_type.replace("wireguard_", "", 1) + if peer_interface != name: + continue + public_key = option_value(peer, "public_key", "") + peers.append( + { + "id": section_name(peer), + "interface": peer_interface, + "public_key_present": bool(public_key), + "public_key_fingerprint": fingerprint(public_key), + "preshared_key_present": bool(option_value(peer, "preshared_key")), + "allowed_ips": string_list(option_value(peer, "allowed_ips", [])), + "route_allowed_ips": to_bool( + option_value(peer, "route_allowed_ips"), default=False + ), + "endpoint_present": bool( + option_value(peer, "endpoint_host") + or option_value(peer, "endpoint_port") + ), + "persistent_keepalive": str( + option_value(peer, "persistent_keepalive", "") + ), + "unredacted_secret_detected": has_unredacted_secret(peer), + } + ) + status_peers = _status_peers(status, name) + if status_peers: + peers_by_fingerprint = { + peer.get("public_key_fingerprint"): peer + for peer in peers + if peer.get("public_key_fingerprint") + } + for status_peer in status_peers: + match = peers_by_fingerprint.get( + status_peer.get("public_key_fingerprint") + ) + if match: + match["latest_handshake_age_seconds"] = status_peer.get( + "latest_handshake_age_seconds" + ) + match["endpoint_present"] = match.get( + "endpoint_present" + ) or status_peer.get("endpoint_present") + else: + peers.append(status_peer) + private_key = option_value(section, "private_key", "") + interfaces.append( + { + "id": name, + "listen_port": str(option_value(section, "listen_port", "")), + "addresses": string_list( + option_value(section, ("addresses", "ipaddr"), []) + ), + "private_key_present": bool(private_key), + "unredacted_secret_detected": has_unredacted_secret( + {"private_key": private_key} + ), + "peers": peers, + } + ) + return interfaces + + +def _swanctl_inventory(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + content = get_file(raw_snapshot, "/etc/swanctl/swanctl.conf") + if not content: + return {"file_present": False, "configured": False} + active_lines = [ + line.strip() + for line in content.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + return { + "file_present": True, + "configured": bool(active_lines), + "active_directive_count": len(active_lines), + } + + +def _ipsec_inventory(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + remotes = [] + tunnels = [] + proposals = [] + for section in uci_sections(raw_snapshot, "ipsec"): + item_type = section_type(section) + if item_type == "remote": + remotes.append( + { + "id": section_name(section), + "name": str(option_value(section, "ns_name", "")), + "enabled": is_enabled_section(section, default=False), + "gateway": str(option_value(section, "gateway", "")), + "authentication_method": str( + option_value(section, "authentication_method", "") + ), + "keyexchange": str(option_value(section, "keyexchange", "")), + "preshared_key_present": bool( + option_value(section, "pre_shared_key") + ), + "tunnels": string_list(option_value(section, "tunnel", [])), + "crypto_proposals": string_list( + option_value(section, "crypto_proposal", []) + ), + } + ) + elif item_type == "tunnel": + tunnels.append( + { + "id": section_name(section), + "local_subnets": string_list( + option_value(section, "local_subnet", []) + ), + "remote_subnets": string_list( + option_value(section, "remote_subnet", []) + ), + "startaction": str(option_value(section, "startaction", "")), + "closeaction": str(option_value(section, "closeaction", "")), + } + ) + elif item_type == "crypto_proposal": + proposals.append( + { + "id": section_name(section), + "encryption_algorithm": str( + option_value(section, "encryption_algorithm", "") + ), + "hash_algorithm": str( + option_value(section, "hash_algorithm", "") + ), + "dh_group": str(option_value(section, "dh_group", "")), + } + ) + return { + "configured": bool(remotes or tunnels or proposals), + "enabled_connection_count": len( + [remote for remote in remotes if remote.get("enabled")] + ), + "remote_count": len(remotes), + "tunnel_count": len(tunnels), + "authentication_methods": sorted( + { + method + for method in ( + str(remote.get("authentication_method", "")) for remote in remotes + ) + if method + } + ), + "connections": remotes, + "proposals": proposals, + "swanctl": _swanctl_inventory(raw_snapshot), + } + + +def analyze(raw_snapshot: Mapping[str, Any]) -> dict[str, Any]: + openvpn_instances = _openvpn_instances(raw_snapshot) + wireguard_interfaces = _wireguard_inventory(raw_snapshot) + ipsec_inventory = _ipsec_inventory(raw_snapshot) + inventory = { + "openvpn": openvpn_instances, + "wireguard": wireguard_interfaces, + "ipsec": ipsec_inventory, + "enabled_openvpn_count": len( + [instance for instance in openvpn_instances if instance.get("enabled")] + ), + "wireguard_interface_count": len(wireguard_interfaces), + "enabled_ipsec_count": int(ipsec_inventory.get("enabled_connection_count", 0)), + } + findings = [] + + for instance in openvpn_instances: + if instance.get("unredacted_secret_detected"): + findings.append( + build_finding( + f"vpn-openvpn-unredacted-secret-{instance['id']}", + "OpenVPN analyzer input contains unredacted secret material", + "critical", + AREA, + "openvpn", + "The sanitized snapshot still appears to contain OpenVPN secret material.", + {"instance": instance.get("id")}, + "Fix collection redaction rules and regenerate the sanitized snapshot before sharing report artifacts.", + nist=("AU-9", "IA-5", "SC-13"), + acn=("secure_communications", "risk_management"), + ) + ) + if not instance.get("enabled"): + continue + tls_min = _tls_version_number(instance.get("tls_version_min")) + if tls_min is None: + findings.append( + build_finding( + f"vpn-openvpn-no-tls-min-{instance['id']}", + "OpenVPN TLS minimum version is not explicit", + "medium", + AREA, + "openvpn", + "An enabled OpenVPN instance does not declare tls_version_min in the sanitized UCI data.", + {"instance": instance.get("id")}, + "Set tls-version-min to 1.2 or higher, preferably 1.3 where compatible.", + nist=("SC-8", "SC-13", "CM-6"), + acn=("secure_communications", "risk_management"), + ) + ) + elif tls_min < 1.2: + findings.append( + build_finding( + f"vpn-openvpn-old-tls-{instance['id']}", + "OpenVPN permits deprecated TLS versions", + "high", + AREA, + "openvpn", + "An enabled OpenVPN instance allows TLS versions older than 1.2.", + { + "instance": instance.get("id"), + "tls_version_min": instance.get("tls_version_min"), + }, + "Raise tls-version-min to 1.2 or newer and test client compatibility.", + nist=("SC-8", "SC-13"), + acn=("secure_communications",), + ) + ) + if instance.get("weak_cipher_values"): + findings.append( + build_finding( + f"vpn-openvpn-weak-cipher-{instance['id']}", + "OpenVPN uses weak or legacy cipher settings", + "high", + AREA, + "openvpn", + "Weak OpenVPN cipher tokens were found in enabled instance settings.", + { + "instance": instance.get("id"), + "weak_cipher_values": instance.get("weak_cipher_values"), + }, + "Use AEAD ciphers such as AES-256-GCM, AES-128-GCM, or CHACHA20-POLY1305.", + nist=("SC-8", "SC-13", "CM-6"), + acn=("secure_communications", "risk_management"), + ) + ) + if any(token in instance.get("auth_digest", "") for token in WEAK_AUTH_TOKENS): + findings.append( + build_finding( + f"vpn-openvpn-weak-auth-{instance['id']}", + "OpenVPN uses weak digest authentication", + "medium", + AREA, + "openvpn", + "The OpenVPN auth directive references a deprecated digest algorithm.", + { + "instance": instance.get("id"), + "auth_digest": instance.get("auth_digest"), + }, + "Use SHA256 or stronger digest settings where an auth directive is still required.", + nist=("SC-8", "SC-13"), + acn=("secure_communications",), + ) + ) + if instance.get("compression_enabled"): + findings.append( + build_finding( + f"vpn-openvpn-compression-{instance['id']}", + "OpenVPN compression is enabled", + "medium", + AREA, + "openvpn", + "VPN compression can expose traffic to compression oracle attacks and is discouraged.", + {"instance": instance.get("id")}, + "Disable comp-lzo/compress/allow-compression unless a documented exception is approved.", + nist=("SC-8", "SC-13", "CM-6"), + acn=("secure_communications", "risk_management"), + ) + ) + if instance.get("duplicate_cn"): + findings.append( + build_finding( + f"vpn-openvpn-duplicate-cn-{instance['id']}", + "OpenVPN duplicate certificate common names are allowed", + "high", + AREA, + "openvpn", + "duplicate-cn weakens user/device accountability for remote access sessions.", + {"instance": instance.get("id")}, + "Disable duplicate-cn and issue unique certificates per user or device.", + nist=("AC-2", "IA-2", "IA-5", "AC-17"), + acn=("identity_access_governance", "secure_communications"), + ) + ) + if instance.get("client_to_client"): + findings.append( + build_finding( + f"vpn-openvpn-client-to-client-{instance['id']}", + "OpenVPN clients can communicate directly with each other", + "medium", + AREA, + "openvpn", + "client-to-client allows lateral traffic between VPN clients.", + {"instance": instance.get("id")}, + "Disable client-to-client unless required and enforce segmentation with firewall rules.", + nist=("AC-4", "SC-7", "CM-7"), + acn=("network_segmentation", "secure_communications"), + ) + ) + if instance.get("user_password_auth") and not ( + instance.get("mfa_indicator") + or contains_indicator(raw_snapshot, MFA_INDICATORS) + ): + findings.append( + build_finding( + f"vpn-openvpn-mfa-missing-{instance['id']}", + "OpenVPN user authentication lacks an MFA indicator", + "medium", + AREA, + "openvpn", + "The enabled OpenVPN instance appears to use user/password authentication without MFA evidence.", + {"instance": instance.get("id")}, + "Enable OTP/MFA for VPN users or document an equivalent compensating control.", + nist=("IA-2", "IA-5", "AC-17"), + acn=("identity_access_governance", "secure_communications"), + ) + ) + + for interface in wireguard_interfaces: + if interface.get("unredacted_secret_detected"): + findings.append( + build_finding( + f"vpn-wireguard-unredacted-secret-{interface['id']}", + "WireGuard analyzer input contains unredacted key material", + "critical", + AREA, + "wireguard", + "The sanitized snapshot still appears to contain WireGuard private key material.", + {"interface": interface.get("id")}, + "Fix collection redaction rules and regenerate the sanitized snapshot before sharing report artifacts.", + nist=("AU-9", "IA-5", "SC-13"), + acn=("secure_communications", "risk_management"), + ) + ) + if not interface.get("private_key_present"): + findings.append( + build_finding( + f"vpn-wireguard-missing-private-key-{interface['id']}", + "WireGuard interface has no private key indicator", + "high", + AREA, + "wireguard", + "A WireGuard interface was found without a private key presence indicator in the sanitized snapshot.", + {"interface": interface.get("id")}, + "Verify the interface configuration; the collector should record presence while redacting the key value.", + nist=("CM-6", "SC-13"), + acn=("secure_communications", "risk_management"), + ) + ) + for peer in interface.get("peers", []): + if peer.get("unredacted_secret_detected"): + findings.append( + build_finding( + f"vpn-wireguard-peer-unredacted-secret-{interface['id']}-{peer.get('id')}", + "WireGuard peer input contains unredacted preshared key material", + "critical", + AREA, + "wireguard_peer", + "The sanitized snapshot still appears to contain WireGuard peer secret material.", + {"interface": interface.get("id"), "peer": peer.get("id")}, + "Fix collection redaction rules and regenerate the sanitized snapshot before sharing report artifacts.", + nist=("AU-9", "IA-5", "SC-13"), + acn=("secure_communications", "risk_management"), + ) + ) + if not peer.get("preshared_key_present"): + findings.append( + build_finding( + f"vpn-wireguard-peer-no-psk-{interface['id']}-{peer.get('id')}", + "WireGuard peer has no preshared key indicator", + "low", + AREA, + "wireguard_peer", + "WireGuard works without PSKs, but PSKs provide additional protection if static keys are exposed later.", + {"interface": interface.get("id"), "peer": peer.get("id")}, + "Consider enabling per-peer preshared keys and rotate them periodically.", + nist=("IA-5", "SC-13"), + acn=("secure_communications", "risk_management"), + ) + ) + allowed = set(peer.get("allowed_ips", [])) + if allowed & FULL_TUNNEL_ALLOWED_IPS: + findings.append( + build_finding( + f"vpn-wireguard-peer-full-tunnel-{interface['id']}-{peer.get('id')}", + "WireGuard peer is allowed a full-tunnel route", + "medium", + AREA, + "wireguard_peer", + "A peer allowed_ips entry permits 0.0.0.0/0 or ::/0, which can expand remote access scope.", + { + "interface": interface.get("id"), + "peer": peer.get("id"), + "allowed_ips": peer.get("allowed_ips"), + }, + "Confirm full-tunnel access is required; otherwise restrict allowed_ips to specific networks.", + nist=("AC-4", "AC-17", "SC-7"), + acn=("network_segmentation", "secure_communications"), + ) + ) + age = peer.get("latest_handshake_age_seconds") + if isinstance(age, int) and age > STALE_HANDSHAKE_SECONDS: + findings.append( + build_finding( + f"vpn-wireguard-stale-peer-{interface['id']}-{peer.get('id')}", + "WireGuard peer handshake is stale", + "low", + AREA, + "wireguard_peer", + "A configured peer has not completed a handshake within the expected audit window.", + { + "interface": interface.get("id"), + "peer": peer.get("id"), + "age_seconds": age, + }, + "Review whether the peer is still needed and remove or disable unused remote access entries.", + nist=("AC-2", "AC-17", "CM-7"), + acn=("identity_access_governance", "risk_management"), + ) + ) + + return build_result(AREA, inventory, findings) + + +analyze_vpn = analyze diff --git a/packages/ns-audit/files/ns_audit/cli.py b/packages/ns-audit/files/ns_audit/cli.py new file mode 100644 index 000000000..3d3708618 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/cli.py @@ -0,0 +1,339 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import tarfile +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from ns_audit import __version__ +from ns_audit.collectors import collect_all +from ns_audit.config import ( + COMPLIANCE_MAPPING_NAME, + DEFAULT_OUTPUT_DIR, + FINDINGS_NAME, + INVENTORY_NAME, + RAW_SNAPSHOT_NAME, + REPORT_NAME, + REPORT_BUNDLE_NAME, + SUMMARY_NAME, +) +from ns_audit.models import AuditError +from ns_audit.sanitize import sanitize_snapshot + + +def _json_default(value: object) -> str: + return str(value) + + +def _write_json(path: Path, payload: object) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump( + payload, + handle, + ensure_ascii=False, + indent=2, + sort_keys=True, + default=_json_default, + ) + handle.write("\n") + return path + + +def _read_json(path: Path) -> Any: + try: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError as exc: + raise AuditError("input_not_found", f"Input not found: {path}") from exc + except json.JSONDecodeError as exc: + raise AuditError("invalid_json", f"Invalid JSON input {path}: {exc}") from exc + + +def _hash_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(64 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _snapshot_path(output: str | Path) -> Path: + path = Path(output) + if path.suffix == ".json": + return path + return path / RAW_SNAPSHOT_NAME + + +def _output_dir(output: str | Path) -> Path: + path = Path(output) + if path.suffix == ".json": + return path.parent + return path + + +def collect(output: str | Path) -> dict[str, object]: + snapshot = sanitize_snapshot(collect_all()) + raw_snapshot = _write_json(_snapshot_path(output), snapshot) + return {"raw_snapshot": str(raw_snapshot), "sha256": _hash_file(raw_snapshot)} + + +def _source_count(value: object) -> int: + if isinstance(value, dict | list | tuple): + return len(value) + return 0 + + +def _stub_analyze_snapshot(snapshot: dict[str, object]) -> dict[str, object]: + sources = snapshot.get("sources", {}) + source_counts = ( + {name: _source_count(value) for name, value in sources.items()} + if isinstance(sources, dict) + else {} + ) + generated_at = datetime.now(UTC).isoformat() + return { + INVENTORY_NAME: { + "schema_version": 1, + "generated_at": generated_at, + "status": "analysis_stub", + "source_counts": source_counts, + }, + FINDINGS_NAME: { + "schema_version": 1, + "generated_at": generated_at, + "status": "analysis_stub", + "findings": [], + }, + COMPLIANCE_MAPPING_NAME: { + "schema_version": 1, + "generated_at": generated_at, + "status": "analysis_stub", + "mappings": [], + }, + SUMMARY_NAME: { + "schema_version": 1, + "generated_at": generated_at, + "status": "analysis_stub", + "message": "Analysis engine is not implemented in this package scope.", + "finding_counts": {"critical": 0, "high": 0, "medium": 0, "low": 0}, + }, + } + + +def _load_analyzer(): + try: + from ns_audit.analyzers import build_analysis_outputs + except ImportError: + try: + from ns_audit.analyzers import analyze_snapshot + except ImportError: + return _stub_analyze_snapshot + return analyze_snapshot + return build_analysis_outputs + + +def _artifact_file_name(name: str) -> str: + return { + "inventory": INVENTORY_NAME, + "findings": FINDINGS_NAME, + "compliance": COMPLIANCE_MAPPING_NAME, + "compliance_mapping": COMPLIANCE_MAPPING_NAME, + "summary": SUMMARY_NAME, + }.get(name, name) + + +def analyze( + input_path: str | Path, output: str | Path | None = None +) -> dict[str, object]: + raw_snapshot = ( + _snapshot_path(input_path) if Path(input_path).is_dir() else Path(input_path) + ) + output_dir = _output_dir(output or raw_snapshot.parent) + snapshot = _read_json(raw_snapshot) + if not isinstance(snapshot, dict): + raise AuditError("invalid_snapshot", "Raw snapshot must contain a JSON object") + + artifacts = _load_analyzer()(snapshot) + paths = {} + for file_name, payload in artifacts.items(): + artifact_name = _artifact_file_name(str(file_name)) + paths[artifact_name] = str( + _write_json(output_dir / artifact_name, sanitize_snapshot(payload)) + ) + return {"artifacts": paths} + + +def _stub_render_report(input_dir: Path, output_path: Path) -> Path: + summary_path = input_dir / SUMMARY_NAME + summary = ( + _read_json(summary_path) if summary_path.exists() else {"status": "report_stub"} + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + escaped_summary = html.escape( + json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True) + ) + body = ( + "\n" + 'NethSecurity audit report\n' + "

NethSecurity audit report

\n" + "

Report rendering is a placeholder in this package scope.

\n" + f"
{escaped_summary}
\n" + "\n" + ) + output_path.write_text(body, encoding="utf-8") + return output_path + + +def _stub_bundle_report(input_dir: Path, output_path: Path) -> Path: + _stub_render_report(input_dir, input_dir / REPORT_NAME) + output_path.parent.mkdir(parents=True, exist_ok=True) + root_name = "audit-report" + excluded = {output_path.resolve()} + files = sorted( + path + for path in input_dir.rglob("*") + if path.is_file() and path.resolve() not in excluded + ) + with tarfile.open(output_path, "w:gz") as archive: + for path in files: + arcname = str(Path(root_name) / path.relative_to(input_dir)) + info = tarfile.TarInfo(arcname) + stat = path.stat() + info.size = stat.st_size + info.mode = stat.st_mode & 0o777 + info.mtime = int(stat.st_mtime) + with path.open("rb") as handle: + archive.addfile(info, handle) + return output_path + + +def _load_reporter(): + try: + from ns_audit.reporting import render_report + except ImportError: + return _stub_render_report + return render_report + + +def _load_bundle_reporter(): + try: + from ns_audit.reporting import bundle_report_artifacts + except ImportError: + return _stub_bundle_report + return bundle_report_artifacts + + +def _is_tarball_path(path: Path) -> bool: + return path.name.endswith(".tar.gz") or path.suffix == ".tgz" + + +def report( + input_path: str | Path, output: str | Path | None = None +) -> dict[str, object]: + input_dir = Path(input_path) + if input_dir.is_file(): + input_dir = input_dir.parent + output_path = Path(output) if output else input_dir / REPORT_NAME + if _is_tarball_path(output_path): + bundle_path = _load_bundle_reporter()(input_dir, output_path) + return { + "report": str(input_dir / REPORT_NAME), + "bundle": str(bundle_path), + } + report_path = _load_reporter()(input_dir, output_path) + return {"report": str(report_path)} + + +def run(output: str | Path) -> dict[str, object]: + output_dir = Path(output) + collect_result = collect(output_dir) + analyze_result = analyze(collect_result["raw_snapshot"], output_dir) + report_result = report(output_dir, output_dir / REPORT_BUNDLE_NAME) + return { + "collect": collect_result, + "analyze": analyze_result, + "report": report_result, + } + + +def _print_result(payload: object) -> None: + print( + json.dumps(payload, ensure_ascii=False, sort_keys=True, default=_json_default) + ) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="NethSecurity on-device read-only audit collector" + ) + parser.add_argument( + "--version", action="version", version=f"ns-audit {__version__}" + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + collect_parser = subparsers.add_parser( + "collect", help="Collect sanitized local evidence" + ) + collect_parser.add_argument( + "--output", + default=DEFAULT_OUTPUT_DIR, + help="Output directory or raw_snapshot.json path", + ) + collect_parser.set_defaults(func=lambda args: collect(args.output)) + + analyze_parser = subparsers.add_parser( + "analyze", help="Analyze a sanitized raw snapshot" + ) + analyze_parser.add_argument( + "--input", + required=True, + help="Input raw_snapshot.json path or containing directory", + ) + analyze_parser.add_argument("--output", help="Output directory for JSON artifacts") + analyze_parser.set_defaults(func=lambda args: analyze(args.input, args.output)) + + report_parser = subparsers.add_parser( + "report", help="Render an audit report from analysis artifacts" + ) + report_parser.add_argument( + "--input", required=True, help="Directory containing analysis artifacts" + ) + report_parser.add_argument("--output", help="Output HTML report path") + report_parser.set_defaults(func=lambda args: report(args.input, args.output)) + + run_parser = subparsers.add_parser( + "run", help="Collect evidence and run available analysis/report stages" + ) + run_parser.add_argument( + "--output", + default=DEFAULT_OUTPUT_DIR, + help="Output directory for all artifacts", + ) + run_parser.set_defaults(func=lambda args: run(args.output)) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + try: + _print_result(args.func(args)) + except AuditError as exc: + _print_result({"error": exc.code, "message": exc.message}) + return 1 + except OSError as exc: + _print_result({"error": "io_error", "message": str(exc)}) + return 1 + return 0 diff --git a/packages/ns-audit/files/ns_audit/collectors/__init__.py b/packages/ns-audit/files/ns_audit/collectors/__init__.py new file mode 100644 index 000000000..d9dc27f67 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/collectors/__init__.py @@ -0,0 +1,121 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +from datetime import UTC, datetime + +from ns_audit import __version__ +from ns_audit.collectors.commands import ( + collect_automatic_updates, + collect_default_password_check, + collect_storage_status, + collect_subscription, + collect_ubus, + collect_update_check, + collect_wireguard, +) +from ns_audit.collectors.local_files import collect_local_files +from ns_audit.collectors.logs import collect_logs +from ns_audit.collectors.uci import collect_uci_packages +from ns_audit.collectors.victoria_logs import collect_victoria_logs + + +def _file_map(files: list[dict[str, object]]) -> dict[str, dict[str, object]]: + return {str(entry["path"]): entry for entry in files if "path" in entry} + + +def _uci_map( + uci_result: dict[str, object], files: dict[str, dict[str, object]] +) -> dict[str, object]: + packages = uci_result.get("packages") + if not isinstance(packages, dict): + packages = {} + + configs: dict[str, object] = {} + for package, result in packages.items(): + if isinstance(result, dict) and result.get("stdout"): + configs[str(package)] = result["stdout"] + + for path, entry in files.items(): + prefix = "/etc/config/" + if not path.startswith(prefix) or not entry.get("readable"): + continue + package = path.removeprefix(prefix) + configs.setdefault(package, entry.get("content", "")) + return configs + + +def _command_map( + ubus: dict[str, object], wireguard: dict[str, object], logs: dict[str, object] +) -> dict[str, object]: + commands: dict[str, object] = {} + for name, result in ubus.items(): + if isinstance(result, dict): + commands[" ".join(result.get("args", [])) or name] = result + wg_result = wireguard.get("result") + if isinstance(wg_result, dict): + commands[" ".join(wg_result.get("args", [])) or "wireguard"] = wg_result + logread = logs.get("logread") + if isinstance(logread, dict): + commands[" ".join(logread.get("args", [])) or "logread"] = logread + return commands + + +def collect_all() -> dict[str, object]: + files = collect_local_files() + uci = collect_uci_packages() + ubus = collect_ubus() + wireguard = collect_wireguard() + storage_status = collect_storage_status() + logs = collect_logs() + victoria_logs = collect_victoria_logs() + files_by_path = _file_map(files) + + # Extract local version for update check + board_cmd = ubus.get("system_board") or {} + board_json = board_cmd.get("parsed_json") or {} + release = board_json.get("release") or {} + local_version = str(release.get("version", "")).strip() or None + + default_password = collect_default_password_check() + update_check = collect_update_check(local_version=local_version) + automatic_updates = collect_automatic_updates() + subscription = collect_subscription() + + security_checks = { + "default_pw_check": default_password, + "update": update_check, + "automatic_updates": automatic_updates, + "subscription": subscription, + } + + return { + "schema_version": 1, + "collector": { + "name": "ns-audit", + "version": __version__, + "collected_at": datetime.now(UTC).isoformat(), + "mode": "local_read_only", + }, + "files": files_by_path, + "uci": _uci_map(uci, files_by_path), + "commands": _command_map(ubus, wireguard, logs), + "storage_status": storage_status, + "logs": logs, + "wireguard": wireguard, + "victoria_logs": victoria_logs, + "security_checks": security_checks, + "sources": { + "files": files, + "uci": uci, + "ubus": ubus, + "wireguard": wireguard, + "storage_status": storage_status, + "logs": logs, + "victoria_logs": victoria_logs, + }, + } diff --git a/packages/ns-audit/files/ns_audit/collectors/commands.py b/packages/ns-audit/files/ns_audit/collectors/commands.py new file mode 100644 index 000000000..7ab3d8c02 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/collectors/commands.py @@ -0,0 +1,256 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +import json +import shutil +import subprocess +import urllib.error +import urllib.request +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from ns_audit.config import COMMAND_OUTPUT_BYTES, COMMAND_TIMEOUT, LATEST_RELEASE_URL, UBUS_CALLS, UPDATE_CHECK_TIMEOUT +from ns_audit.models import CommandEvidence + +_DEFAULT_PASSWORDS = ("Nethesis,1234",) + + +def _decode_limited(data: bytes | None, max_bytes: int) -> tuple[str, bool]: + if not data: + return "", False + truncated = len(data) > max_bytes + limited = data[:max_bytes] + text = limited.decode("utf-8", "replace") + if truncated: + text = f"{text}\n" + return text, truncated + + +def _parse_json(text: str) -> Any: + if not text.strip(): + return None + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +def command_exists(command: str) -> bool: + return shutil.which(command) is not None + + +def run_command( + args: Sequence[str], + *, + name: str | None = None, + timeout: int = COMMAND_TIMEOUT, + max_bytes: int = COMMAND_OUTPUT_BYTES, +) -> dict[str, object]: + command = list(args) + evidence_name = name or " ".join(command) + if not command: + return CommandEvidence( + name=evidence_name, args=[], found=False, error="empty_command" + ).to_dict() + if not command_exists(command[0]): + return CommandEvidence( + name=evidence_name, args=command, found=False, error="command_not_found" + ).to_dict() + + try: + completed = subprocess.run( + command, capture_output=True, check=False, timeout=timeout + ) + except subprocess.TimeoutExpired as exc: + stdout, stdout_truncated = _decode_limited(exc.stdout, max_bytes) + stderr, stderr_truncated = _decode_limited(exc.stderr, max_bytes) + return CommandEvidence( + name=evidence_name, + args=command, + found=True, + stdout=stdout, + stderr=stderr, + timed_out=True, + truncated=stdout_truncated or stderr_truncated, + error="timeout", + ).to_dict() + except OSError as exc: + return CommandEvidence( + name=evidence_name, args=command, found=True, error=str(exc) + ).to_dict() + + stdout, stdout_truncated = _decode_limited(completed.stdout, max_bytes) + stderr, stderr_truncated = _decode_limited(completed.stderr, max_bytes) + return CommandEvidence( + name=evidence_name, + args=command, + found=True, + returncode=completed.returncode, + stdout=stdout, + stderr=stderr, + truncated=stdout_truncated or stderr_truncated, + parsed_json=_parse_json(stdout), + ).to_dict() + + +def collect_ubus() -> dict[str, object]: + return {name: run_command(args, name=name) for name, args in UBUS_CALLS} + + +def collect_wireguard() -> dict[str, object]: + if command_exists("wg-json"): + return { + "source": "wg-json", + "result": run_command(("wg-json",), name="wg-json"), + } + if command_exists("wg"): + return { + "source": "wg", + "result": run_command(("wg", "show", "all"), name="wg_show_all"), + } + return { + "source": None, + "result": CommandEvidence( + "wireguard", [], found=False, error="command_not_found" + ).to_dict(), + } + + +def collect_storage_status() -> dict[str, object]: + if command_exists("/usr/sbin/storage-status"): + return run_command(("/usr/sbin/storage-status",), name="storage-status") + return CommandEvidence( + "storage-status", + ["/usr/sbin/storage-status"], + found=False, + error="command_not_found", + ).to_dict() + + +def collect_default_password_check() -> dict[str, object]: + """Check if root account uses the known factory-default password. + + Returns only a boolean result — never stores the actual hash or password. + """ + shadow = Path("/etc/shadow") + if not shadow.exists(): + return {"checked": False, "error": "shadow_not_found"} + try: + content = shadow.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return {"checked": False, "error": str(exc)} + + root_hash: str | None = None + for line in content.splitlines(): + if line.startswith("root:"): + parts = line.split(":") + if len(parts) >= 2: + root_hash = parts[1] + break + + if not root_hash or root_hash in {"!", "!!", "*", "x", ""}: + return {"checked": True, "has_pw": False, "is_default": False} + + try: + from passlib.hash import md5_crypt, sha256_crypt, sha512_crypt + + for candidate in _DEFAULT_PASSWORDS: + for verifier in (sha512_crypt, sha256_crypt, md5_crypt): + try: + if verifier.verify(candidate, root_hash): + return {"checked": True, "has_pw": True, "is_default": True} + except Exception: + pass + return {"checked": True, "has_pw": True, "is_default": False} + except ImportError: + return {"checked": True, "has_pw": True, "is_default": None, "error": "passlib_unavailable"} + + +def collect_update_check(local_version: str | None = None) -> dict[str, object]: + """Fetch the latest NethSecurity release version and compare to local.""" + try: + req = urllib.request.Request(LATEST_RELEASE_URL, headers={"User-Agent": "ns-audit/1"}) + with urllib.request.urlopen(req, timeout=UPDATE_CHECK_TIMEOUT) as resp: + latest = resp.read(200).decode("utf-8", "replace").strip().splitlines()[0].strip() + except (urllib.error.URLError, OSError, TimeoutError) as exc: + return {"checked": False, "latest_version": None, "local_version": local_version, "error": str(exc)} + + if not local_version: + return {"checked": True, "latest_version": latest, "local_version": None, "up_to_date": None} + + def _parse(v: str) -> tuple[int, ...]: + # Extract leading numeric version (e.g. "8.8.0" from "8.8.0-nethsecurity-...") + base = v.split("-")[0] + parts = base.split(".") + result = [] + for p in parts: + if p.isdigit(): + result.append(int(p)) + else: + break + return tuple(result) + + try: + up_to_date = _parse(local_version) >= _parse(latest) + except Exception: + up_to_date = None + + return { + "checked": True, + "latest_version": latest, + "local_version": local_version, + "up_to_date": up_to_date, + } + + +def collect_automatic_updates() -> dict[str, object]: + """Check whether automatic updates are enabled via local ubus.""" + result = run_command( + ("ubus", "call", "ns.update", "get-automatic-updates-status"), + name="ns.update get-automatic-updates-status", + ) + parsed = result.get("parsed_json") + if isinstance(parsed, dict): + return { + "checked": True, + "enabled": bool(parsed.get("enabled")), + } + error = str(result.get("error") or result.get("stderr") or "").strip() + return { + "checked": False, + "enabled": None, + "error": error or "no_response", + } + + +def collect_subscription() -> dict[str, object]: + """Collect subscription status via ubus.""" + try: + result = subprocess.run( + ["ubus", "call", "ns.subscription", "info"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout) + active = bool(data.get("active")) + plan = str(data.get("plan", "") or "").strip() + server_id = data.get("server_id") + expiration = data.get("expiration", 0) + return { + "available": True, + "active": active, + "plan": plan if plan else None, + "server_id": server_id, + "expiration": expiration, + } + return {"available": True, "active": False} + except Exception as exc: + return {"available": False, "error": str(exc)} diff --git a/packages/ns-audit/files/ns_audit/collectors/local_files.py b/packages/ns-audit/files/ns_audit/collectors/local_files.py new file mode 100644 index 000000000..e157166ab --- /dev/null +++ b/packages/ns-audit/files/ns_audit/collectors/local_files.py @@ -0,0 +1,66 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +import glob +from pathlib import Path + +from ns_audit.config import CONFIG_DIR_GLOB, CONFIG_FILES, LOGROTATE_GLOBS, MAX_TEXT_FILE_BYTES +from ns_audit.models import FileEvidence + + +def _read_limited_text(path: Path, max_bytes: int) -> FileEvidence: + if not path.exists(): + return FileEvidence( + path=str(path), present=False, readable=False, error="not_found" + ) + if not path.is_file(): + return FileEvidence( + path=str(path), present=True, readable=False, error="not_a_file" + ) + + try: + size = path.stat().st_size + with path.open("rb") as handle: + data = handle.read(max_bytes + 1) + except OSError as exc: + return FileEvidence( + path=str(path), present=True, readable=False, error=str(exc) + ) + + truncated = len(data) > max_bytes or size > max_bytes + content = data[:max_bytes].decode("utf-8", "replace") + return FileEvidence( + path=str(path), + present=True, + readable=True, + size=size, + truncated=truncated, + content=content, + ) + + +def _expand_globs(patterns: tuple[str, ...]) -> list[str]: + paths: set[str] = set() + for pattern in patterns: + paths.update(glob.glob(pattern)) + return sorted(paths) + + +def collect_local_files( + max_bytes: int = MAX_TEXT_FILE_BYTES, +) -> list[dict[str, object]]: + # All files under /etc/config/ plus explicit non-UCI config files and logrotate + paths = [ + *_expand_globs((CONFIG_DIR_GLOB,)), + *CONFIG_FILES, + *_expand_globs(LOGROTATE_GLOBS), + ] + return [ + _read_limited_text(Path(path), max_bytes).to_dict() + for path in dict.fromkeys(paths) + ] diff --git a/packages/ns-audit/files/ns_audit/collectors/logs.py b/packages/ns-audit/files/ns_audit/collectors/logs.py new file mode 100644 index 000000000..42f7da033 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/collectors/logs.py @@ -0,0 +1,129 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +import glob +import gzip +import os +from pathlib import Path + +from ns_audit.collectors.commands import run_command +from ns_audit.config import MAX_LOG_FILE_BYTES, MAX_LOG_LINES + +_STORAGE_LOG_GLOB = "/mnt/data/log/messages*" +_MEMORY_LOG_GLOB = "/var/log/messages*" + + +def _last_lines(text: str, max_lines: int) -> tuple[str, bool]: + lines = text.splitlines() + truncated = len(lines) > max_lines + if truncated: + lines = lines[-max_lines:] + return "\n".join(lines), truncated + + +def _read_gzip_head(path: Path, max_bytes: int, max_lines: int) -> dict[str, object]: + try: + size = path.stat().st_size + with gzip.open(path, "rb") as handle: + data = handle.read(max_bytes + 1) + except OSError as exc: + return {"path": str(path), "present": True, "readable": False, "error": str(exc)} + + truncated = len(data) > max_bytes + content, line_truncated = _last_lines( + data[:max_bytes].decode("utf-8", "replace"), max_lines + ) + return { + "path": str(path), + "present": True, + "readable": True, + "size": size, + "truncated": truncated or line_truncated, + "content": content, + } + + +def _read_text_tail(path: Path, max_bytes: int, max_lines: int) -> dict[str, object]: + if not path.exists(): + return {"path": str(path), "present": False, "readable": False, "error": "not_found"} + if not path.is_file(): + return {"path": str(path), "present": True, "readable": False, "error": "not_a_file"} + if path.suffix == ".gz": + return _read_gzip_head(path, max_bytes, max_lines) + + try: + size = path.stat().st_size + with path.open("rb") as handle: + if size > max_bytes: + handle.seek(-max_bytes, os.SEEK_END) + data = handle.read(max_bytes) + except OSError as exc: + return {"path": str(path), "present": True, "readable": False, "error": str(exc)} + + content, line_truncated = _last_lines(data.decode("utf-8", "replace"), max_lines) + return { + "path": str(path), + "present": True, + "readable": True, + "size": size, + "truncated": size > max_bytes or line_truncated, + "content": content, + } + + +def _expand_glob(pattern: str) -> list[str]: + return sorted(glob.glob(pattern)) + + +def _active_log_source() -> str: + """Return active_source based on storage mount state. + + "storage" if persistent logs exist under /mnt/data/log/, else "memory". + """ + return "storage" if _expand_glob(_STORAGE_LOG_GLOB) else "memory" + + +def collect_log_files( + max_bytes: int = MAX_LOG_FILE_BYTES, max_lines: int = MAX_LOG_LINES +) -> tuple[list[dict[str, object]], list[dict[str, object]], str]: + """Return (primary_files, secondary_files, active_source). + + primary_files comes from the active source (bundled in tar.gz). + secondary_files comes from the other source if available (for analysis only, + not bundled). Both are included in the snapshot so analyzers can see all data. + """ + active_source = _active_log_source() + if active_source == "storage": + primary_pattern, secondary_pattern = _STORAGE_LOG_GLOB, _MEMORY_LOG_GLOB + else: + primary_pattern, secondary_pattern = _MEMORY_LOG_GLOB, _STORAGE_LOG_GLOB + + primary = [_read_text_tail(Path(p), max_bytes, max_lines) for p in _expand_glob(primary_pattern)] + secondary = [_read_text_tail(Path(p), max_bytes, max_lines) for p in _expand_glob(secondary_pattern)] + return primary, secondary, active_source + + +def collect_logread(max_lines: int = MAX_LOG_LINES) -> dict[str, object]: + result = run_command(("logread",), name="logread", max_bytes=MAX_LOG_FILE_BYTES) + stdout = result.get("stdout") + if isinstance(stdout, str): + result["stdout"], lines_truncated = _last_lines(stdout, max_lines) + result["truncated"] = bool(result.get("truncated")) or lines_truncated + return result + + +def collect_logs() -> dict[str, object]: + primary, secondary, active_source = collect_log_files() + return { + "logread": collect_logread(), + "files": primary, + "secondary_files": secondary, + "active_source": active_source, + "limits": {"max_file_bytes": MAX_LOG_FILE_BYTES, "max_lines": MAX_LOG_LINES}, + } + diff --git a/packages/ns-audit/files/ns_audit/collectors/uci.py b/packages/ns-audit/files/ns_audit/collectors/uci.py new file mode 100644 index 000000000..68b0fc385 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/collectors/uci.py @@ -0,0 +1,25 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +from ns_audit.collectors.commands import command_exists, run_command +from ns_audit.config import UCI_PACKAGES + + +def collect_uci_packages() -> dict[str, object]: + if not command_exists("uci"): + return {"available": False, "error": "command_not_found", "packages": {}} + + return { + "available": True, + "packages": { + package: run_command( + ("uci", "-q", "show", package), name=f"uci_show_{package}" + ) + for package in UCI_PACKAGES + }, + } diff --git a/packages/ns-audit/files/ns_audit/collectors/victoria_logs.py b/packages/ns-audit/files/ns_audit/collectors/victoria_logs.py new file mode 100644 index 000000000..65b4a17cb --- /dev/null +++ b/packages/ns-audit/files/ns_audit/collectors/victoria_logs.py @@ -0,0 +1,369 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Collect authentication events from victoria-logs via its HTTP query API. + +Queries the local victoria-logs instance for SSH, UI, and VPN login/logout +events. Falls back gracefully when victoria-logs is unavailable. +""" + +from __future__ import annotations + +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +try: + from ..config import VICTORIA_LOGS_QUERY_LIMIT, VICTORIA_LOGS_URL +except ImportError: # pragma: no cover + from ns_audit.config import VICTORIA_LOGS_QUERY_LIMIT, VICTORIA_LOGS_URL + +VICTORIA_LOGS_TIMEOUT = 5 + +# LogsQL queries for each auth event category +_QUERIES: list[tuple[str, str]] = [ + ( + "ssh", + 'app_name:"dropbear" AND facility_keyword:authpriv', + ), + ( + "ui", + 'app_name:"nethsecurity-api" AND (_msg:"login response" OR _msg:"logout response" OR _msg:"login_success" OR _msg:"logout_success" OR _msg:"authentication success for user" OR _msg:"authentication failed for user")', + ), + ( + "openvpn_auth", + 'app_name:"openvpn-auth" AND _msg:"event=auth"', + ), + ( + "openvpn_connect", + 'app_name:"openvpn-connect" AND _msg:"event=connect"', + ), + ( + "openvpn_disconnect", + 'app_name:"openvpn-disconnect" AND _msg:"event=disconnect"', + ), + ( + "vpn_legacy", + 'app_name:"openvpn" OR app_name:"strongswan" OR app_name:"charon"', + ), +] + +# Patterns to parse SSH dropbear lines +_RE_SSH_AUTH_OK = re.compile( + r"(?:Pubkey auth succeeded|Password auth succeeded) for '?(\S+?)'?" + r"(?: with \S+ key (\S+))? from ([\d.:]+)", + re.I, +) +_RE_SSH_AUTH_FAIL = re.compile( + r"(?:Bad password attempt|Login attempt for nonexistent user)(?: from ([\d.:]+))?", + re.I, +) +_RE_SSH_DISCONNECT = re.compile( + r"Exit (?:\((\S+)\) )?from ?", re.I +) +_RE_SSH_CONNECT = re.compile(r"Child connection from ([\d.:]+)", re.I) + +# Patterns for nethsecurity-api UI events +_RE_UI_LOGIN = re.compile(r"login response success for user (\S+)", re.I) +_RE_UI_LOGIN_FAIL = re.compile(r"login response failed for user (\S+)", re.I) +_RE_UI_LOGOUT = re.compile(r"logout response success for user (\S+)", re.I) +_RE_UI_AUTH_OK = re.compile(r"authentication success for user (\S+)(?: from ([^ ]+))?", re.I) +_RE_UI_AUTH_FAIL = re.compile( + r"authentication failed for user (\S+)(?: from ([^: ]+)(?::\d+)?)?(?:: (.*))?", + re.I, +) + +# Patterns for VPN events +_RE_VPN_CONNECT = re.compile( + r"(?:client connected|peer connected|MULTI_sva|established CHILD_SA)", re.I +) +_RE_VPN_DISCONNECT = re.compile( + r"(?:client disconnected|peer disconnected|SIGTERM|deleting CHILD_SA)", re.I +) +_RE_KEY_VALUE = re.compile(r"(\w+)=([^\s]+)") + + +def _http_get(url: str, params: dict[str, str]) -> str | None: + full_url = f"{url}?{urllib.parse.urlencode(params)}" + try: + with urllib.request.urlopen(full_url, timeout=VICTORIA_LOGS_TIMEOUT) as resp: + return resp.read().decode("utf-8", "replace") + except (urllib.error.URLError, OSError): + return None + + +def _query(base_url: str, logsql: str, limit: int) -> list[dict[str, Any]] | None: + """Run a LogsQL query and return parsed JSON lines.""" + raw = _http_get( + f"{base_url}/select/logsql/query", + {"query": logsql, "limit": str(limit), "start": "30d"}, + ) + if raw is None: + return None + events = [] + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return events + + +def _classify_ssh(entry: dict[str, Any]) -> dict[str, Any] | None: + msg = entry.get("_msg", "") + ts = entry.get("_time", "") + host = entry.get("hostname", "") + + m = _RE_SSH_AUTH_OK.search(msg) + if m: + return { + "time": ts, + "source": "ssh", + "event_type": "login_success", + "user": m.group(1), + "detail": f"key={m.group(2) or 'password'} from {m.group(3)}", + "host": host, + } + + m = _RE_SSH_AUTH_FAIL.search(msg) + if m: + return { + "time": ts, + "source": "ssh", + "event_type": "login_failure", + "user": "", + "detail": f"failed attempt from {m.group(1) or 'unknown'}", + "host": host, + } + + m = _RE_SSH_DISCONNECT.search(msg) + if m: + return { + "time": ts, + "source": "ssh", + "event_type": "logout", + "user": m.group(1) or "", + "detail": f"disconnected from {m.group(2)}", + "host": host, + } + + return None + + +def _classify_ui(entry: dict[str, Any]) -> dict[str, Any] | None: + msg = entry.get("_msg", "") + ts = entry.get("_time", "") + host = entry.get("hostname", "") + + m = _RE_UI_LOGIN.search(msg) + if m: + return { + "time": ts, + "source": "ui", + "event_type": "login_success", + "user": m.group(1), + "detail": "web UI login", + "host": host, + } + + m = _RE_UI_AUTH_OK.search(msg) + if m: + detail = "web UI authentication" + if m.group(2): + detail = f"{detail} from {m.group(2)}" + return { + "time": ts, + "source": "ui", + "event_type": "login_success", + "user": m.group(1), + "detail": detail, + "host": host, + } + + m = _RE_UI_LOGIN_FAIL.search(msg) + if m: + return { + "time": ts, + "source": "ui", + "event_type": "login_failure", + "user": m.group(1), + "detail": "web UI login failed", + "host": host, + } + + m = _RE_UI_AUTH_FAIL.search(msg) + if m: + detail_parts = [] + if m.group(2): + detail_parts.append(f"from {m.group(2)}") + if m.group(3): + detail_parts.append(m.group(3)) + return { + "time": ts, + "source": "ui", + "event_type": "login_failure", + "user": m.group(1), + "detail": " ".join(detail_parts) if detail_parts else "web UI authentication failed", + "host": host, + } + + m = _RE_UI_LOGOUT.search(msg) + if m: + return { + "time": ts, + "source": "ui", + "event_type": "logout", + "user": m.group(1), + "detail": "web UI logout", + "host": host, + } + + return None + + +def _parse_key_value_message(message: str) -> dict[str, str]: + return {key: value for key, value in _RE_KEY_VALUE.findall(message)} + + +def _classify_openvpn_structured(entry: dict[str, Any]) -> dict[str, Any] | None: + msg = entry.get("_msg", "") + ts = entry.get("_time", "") + host = entry.get("hostname", "") + values = _parse_key_value_message(msg) + event = values.get("event") + if not event: + return None + + user = values.get("user", "") + remote_ip = values.get("remote_ip", "") + instance = values.get("instance", "") + detail_parts = [] + if instance: + detail_parts.append(f"instance={instance}") + if remote_ip: + detail_parts.append(f"remote_ip={remote_ip}") + + if event == "auth": + outcome = values.get("outcome", "success").lower() + reason = values.get("reason", "") + if reason: + detail_parts.append(f"reason={reason}") + return { + "time": ts, + "source": "openvpn", + "event_type": "login_success" if outcome == "success" else "login_failure", + "user": user, + "detail": " ".join(detail_parts) if detail_parts else "openvpn authentication", + "host": host, + } + + if event == "connect": + virtual_ip = values.get("virtual_ip", "") + if virtual_ip: + detail_parts.append(f"virtual_ip={virtual_ip}") + return { + "time": ts, + "source": "openvpn", + "event_type": "vpn_connect", + "user": user, + "detail": " ".join(detail_parts) if detail_parts else "openvpn connected", + "host": host, + } + + if event == "disconnect": + virtual_ip = values.get("virtual_ip", "") + duration = values.get("duration", "") + if virtual_ip: + detail_parts.append(f"virtual_ip={virtual_ip}") + if duration: + detail_parts.append(f"duration={duration}") + return { + "time": ts, + "source": "openvpn", + "event_type": "vpn_disconnect", + "user": user, + "detail": " ".join(detail_parts) if detail_parts else "openvpn disconnected", + "host": host, + } + + return None + + +def _classify_vpn(entry: dict[str, Any]) -> dict[str, Any] | None: + msg = entry.get("_msg", "") + ts = entry.get("_time", "") + host = entry.get("hostname", "") + app = entry.get("app_name", "vpn") + + if _RE_VPN_CONNECT.search(msg): + return { + "time": ts, + "source": app, + "event_type": "vpn_connect", + "user": "", + "detail": msg.strip()[:120], + "host": host, + } + if _RE_VPN_DISCONNECT.search(msg): + return { + "time": ts, + "source": app, + "event_type": "vpn_disconnect", + "user": "", + "detail": msg.strip()[:120], + "host": host, + } + return None + + +_CLASSIFIERS = { + "ssh": _classify_ssh, + "ui": _classify_ui, + "openvpn_auth": _classify_openvpn_structured, + "openvpn_connect": _classify_openvpn_structured, + "openvpn_disconnect": _classify_openvpn_structured, + "vpn_legacy": _classify_vpn, +} + + +def collect_victoria_logs( + base_url: str = VICTORIA_LOGS_URL, + limit: int = VICTORIA_LOGS_QUERY_LIMIT, +) -> dict[str, Any]: + """Query victoria-logs for auth events and return structured results.""" + available = False + events: list[dict[str, Any]] = [] + errors: dict[str, str] = {} + + for category, logsql in _QUERIES: + raw_entries = _query(base_url, logsql, limit) + if raw_entries is None: + errors[category] = "query_failed" + continue + if raw_entries: + available = True + + classifier = _CLASSIFIERS[category] + for entry in raw_entries: + classified = classifier(entry) + if classified: + events.append(classified) + + # Sort chronologically (ISO timestamps sort lexicographically) + events.sort(key=lambda e: e.get("time", "")) + + return { + "available": available, + "event_count": len(events), + "events": events, + "errors": errors, + } diff --git a/packages/ns-audit/files/ns_audit/compliance/__init__.py b/packages/ns-audit/files/ns_audit/compliance/__init__.py new file mode 100644 index 000000000..57d92cfa2 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/compliance/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Compliance and scoring entry points for ns-audit.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from . import acn_nis2, nist_800_53r5, scoring + + +def build_compliance_mapping(findings: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + return { + "nist_800_53r5": nist_800_53r5.map_findings(findings), + "acn_nis2": acn_nis2.map_findings(findings), + } + + +def build_summary( + inventory: Mapping[str, Any], findings: Sequence[Mapping[str, Any]] +) -> dict[str, Any]: + return scoring.build_summary(inventory, findings) + + +__all__ = [ + "build_compliance_mapping", + "build_summary", + "nist_800_53r5", + "acn_nis2", + "scoring", +] diff --git a/packages/ns-audit/files/ns_audit/compliance/acn_nis2.py b/packages/ns-audit/files/ns_audit/compliance/acn_nis2.py new file mode 100644 index 000000000..107fd7cb9 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/compliance/acn_nis2.py @@ -0,0 +1,89 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""ACN/NIS2 technical evidence mapping helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +try: + from ..models import ( + ACN_NIS2_CATEGORIES, + json_safe, + normalize_severity, + worst_severity, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + ACN_NIS2_CATEGORIES, + json_safe, + normalize_severity, + worst_severity, + ) + + +def _finding_categories(finding: Mapping[str, Any]) -> list[str]: + compliance = finding.get("compliance") + if not isinstance(compliance, Mapping): + return [] + categories = compliance.get("acn_nis2") or compliance.get("acn") or [] + if isinstance(categories, str): + return [categories] + if isinstance(categories, Sequence): + return [str(category) for category in categories] + return [] + + +def map_findings(findings: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + """Group findings by ACN/NIS2 technical category.""" + mapping: dict[str, Any] = { + category: { + "category": category, + "title": title, + "status": "no_findings", + "severity": "info", + "finding_count": 0, + "findings": [], + "note": "Technical evidence mapping; not a legal compliance determination.", + } + for category, title in ACN_NIS2_CATEGORIES.items() + } + for finding in findings: + for category in _finding_categories(finding): + mapping.setdefault( + category, + { + "category": category, + "title": ACN_NIS2_CATEGORIES.get( + category, category.replace("_", " ").title() + ), + "status": "no_findings", + "severity": "info", + "finding_count": 0, + "findings": [], + "note": "Technical evidence mapping; not a legal compliance determination.", + }, + ) + item = mapping[category] + item["status"] = "attention_required" + item["findings"].append( + { + "id": finding.get("id"), + "title": finding.get("title"), + "severity": normalize_severity(finding.get("severity")), + "area": finding.get("area"), + } + ) + item["finding_count"] = len(item["findings"]) + item["severity"] = worst_severity( + [entry["severity"] for entry in item["findings"]] + ) + return json_safe(mapping) + + +build_category_mapping = map_findings diff --git a/packages/ns-audit/files/ns_audit/compliance/nist_800_53r5.py b/packages/ns-audit/files/ns_audit/compliance/nist_800_53r5.py new file mode 100644 index 000000000..7574d5d9a --- /dev/null +++ b/packages/ns-audit/files/ns_audit/compliance/nist_800_53r5.py @@ -0,0 +1,80 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""NIST SP 800-53 revision 5 compliance mapping helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +try: + from ..models import NIST_CONTROLS, json_safe, normalize_severity, worst_severity +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + NIST_CONTROLS, + json_safe, + normalize_severity, + worst_severity, + ) + + +def _finding_controls(finding: Mapping[str, Any]) -> list[str]: + compliance = finding.get("compliance") + if not isinstance(compliance, Mapping): + return [] + controls = compliance.get("nist_800_53r5") or compliance.get("nist") or [] + if isinstance(controls, str): + return [controls] + if isinstance(controls, Sequence): + return [str(control) for control in controls] + return [] + + +def map_findings(findings: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + """Group findings by NIST SP 800-53r5 control.""" + mapping: dict[str, Any] = { + control: { + "control": control, + "title": title, + "status": "no_findings", + "severity": "info", + "finding_count": 0, + "findings": [], + } + for control, title in NIST_CONTROLS.items() + } + for finding in findings: + for control in _finding_controls(finding): + mapping.setdefault( + control, + { + "control": control, + "title": NIST_CONTROLS.get(control, "NIST SP 800-53r5 control"), + "status": "no_findings", + "severity": "info", + "finding_count": 0, + "findings": [], + }, + ) + item = mapping[control] + item["status"] = "attention_required" + item["findings"].append( + { + "id": finding.get("id"), + "title": finding.get("title"), + "severity": normalize_severity(finding.get("severity")), + "area": finding.get("area"), + } + ) + item["finding_count"] = len(item["findings"]) + item["severity"] = worst_severity( + [entry["severity"] for entry in item["findings"]] + ) + return json_safe(mapping) + + +build_control_mapping = map_findings diff --git a/packages/ns-audit/files/ns_audit/compliance/scoring.py b/packages/ns-audit/files/ns_audit/compliance/scoring.py new file mode 100644 index 000000000..488d55bad --- /dev/null +++ b/packages/ns-audit/files/ns_audit/compliance/scoring.py @@ -0,0 +1,160 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Weighted scoring for the NethSecurity audit report.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +try: + from ..models import ( + AREA_TITLES, + AREA_WEIGHTS, + json_safe, + normalize_severity, + severity_counts, + ) +except ImportError: # pragma: no cover - supports direct execution during development + from ns_audit.models import ( + AREA_TITLES, + AREA_WEIGHTS, + json_safe, + normalize_severity, + severity_counts, + ) + +DEDUCTION_FACTOR = { + "critical": 0.45, + "high": 0.30, + "medium": 0.15, + "low": 0.05, + "info": 0.0, +} + + +def _inventory_counts(inventory: Mapping[str, Any]) -> dict[str, int]: + counts: dict[str, int] = {} + for area, value in inventory.items(): + if isinstance(value, Mapping): + total = 0 + for item in value.values(): + total += ( + len(item) + if isinstance(item, Sequence) + and not isinstance(item, str | bytes | bytearray) + else 1 + ) + counts[str(area)] = total + elif isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ): + counts[str(area)] = len(value) + else: + counts[str(area)] = 1 if value else 0 + return counts + + +def calculate_area_scores(findings: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + area_scores: dict[str, Any] = {} + for area, weight in AREA_WEIGHTS.items(): + area_findings = [ + finding + for finding in findings + if finding.get("score_area", finding.get("area")) == area + ] + deductions = [] + deduction_total = 0.0 + for finding in area_findings: + severity = normalize_severity(finding.get("severity")) + points = round(weight * DEDUCTION_FACTOR[severity], 2) + deduction_total += points + deductions.append( + { + "finding_id": finding.get("id"), + "severity": severity, + "points": points, + } + ) + capped_deduction = min(float(weight), deduction_total) + earned = round(max(0.0, float(weight) - capped_deduction), 2) + percent = round((earned / float(weight)) * 100) if weight else 100 + counts = severity_counts(area_findings) + status = ( + "red" + if counts["critical"] or counts["high"] + else "yellow" + if counts["medium"] + else "green" + ) + area_scores[area] = { + "title": AREA_TITLES.get(area, area.title()), + "weight": weight, + "earned_points": earned, + "deducted_points": round(capped_deduction, 2), + "raw_deducted_points": round(deduction_total, 2), + "score": percent, + "status": status, + "finding_counts": counts, + "deductions": deductions, + } + return json_safe(area_scores) + + +def build_summary( + inventory: Mapping[str, Any], findings: Sequence[Mapping[str, Any]] +) -> dict[str, Any]: + """Build a weighted, JSON-serializable summary from inventory and findings.""" + area_scores = calculate_area_scores(findings) + total_weight = sum(item["weight"] for item in area_scores.values()) + earned_points = sum(item["earned_points"] for item in area_scores.values()) + overall_score = round((earned_points / total_weight) * 100) if total_weight else 100 + counts = severity_counts(findings) + status = ( + "red" + if counts["critical"] or counts["high"] + else "yellow" + if counts["medium"] + else "green" + ) + top_findings = sorted( + findings, + key=lambda finding: (finding.get("risk_score", 0), str(finding.get("id"))), + reverse=True, + )[:10] + return json_safe( + { + "overall_score": overall_score, + "status": status, + "weighted_points": { + "earned": round(earned_points, 2), + "available": total_weight, + }, + "area_scores": area_scores, + "finding_counts": counts, + "finding_count": sum(counts.values()), + "inventory_counts": _inventory_counts(inventory), + "top_findings": [ + { + "id": finding.get("id"), + "title": finding.get("title"), + "severity": finding.get("severity"), + "area": finding.get("area"), + "remediation": finding.get("remediation"), + } + for finding in top_findings + ], + "scoring_model": { + "weights": AREA_WEIGHTS, + "deduction_factor": DEDUCTION_FACTOR, + "note": "Telemetry findings are scored inside the logging and audit trail area.", + }, + } + ) + + +summarize = build_summary diff --git a/packages/ns-audit/files/ns_audit/config.py b/packages/ns-audit/files/ns_audit/config.py new file mode 100644 index 000000000..42044dc09 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/config.py @@ -0,0 +1,70 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +DEFAULT_OUTPUT_DIR = "/var/run/ns-audit/latest" +RAW_SNAPSHOT_NAME = "raw_snapshot.json" +INVENTORY_NAME = "inventory.json" +FINDINGS_NAME = "findings.json" +COMPLIANCE_MAPPING_NAME = "compliance_mapping.json" +SUMMARY_NAME = "summary.json" +REPORT_NAME = "audit-report.html" +REPORT_BUNDLE_NAME = "audit-report.tar.gz" + +COMMAND_TIMEOUT = 8 +COMMAND_OUTPUT_BYTES = 256 * 1024 +MAX_TEXT_FILE_BYTES = 256 * 1024 +MAX_LOG_FILE_BYTES = 512 * 1024 +MAX_LOG_LINES = 5000 + +VICTORIA_LOGS_URL = "http://127.0.0.1:9428" +VICTORIA_LOGS_QUERY_LIMIT = 500 + +LATEST_RELEASE_URL = "https://updates.nethsecurity.nethserver.org/latest_release" +UPDATE_CHECK_TIMEOUT = 10 + +# All files under /etc/config/ are collected via glob; this tuple holds +# non-UCI files that are also collected for identity/system analysis. +CONFIG_DIR_GLOB = "/etc/config/*" +CONFIG_FILES = ( + "/etc/passwd", + "/etc/group", + "/etc/os-release", + "/etc/openwrt_release", + "/etc/swanctl/swanctl.conf", +) + +LOGROTATE_GLOBS = ( + "/etc/logrotate.conf", + "/etc/logrotate.d/*", +) + +LOG_FILE_GLOBS = ( + "/var/log/messages*", + "/mnt/data/log/messages*", +) + +UCI_PACKAGES = ( + "firewall", + "network", + "system", + "dropbear", + "rpcd", + "users", + "openvpn", + "snort", + "suricata", + "rsyslog", + "banip", + "ns-plug", + "acme", + "keepalived", +) + +UBUS_CALLS = ( + ("system_board", ("ubus", "call", "system", "board")), + ("system_info", ("ubus", "call", "system", "info")), + ("service_list", ("ubus", "call", "service", "list")), +) diff --git a/packages/ns-audit/files/ns_audit/models.py b/packages/ns-audit/files/ns_audit/models.py new file mode 100644 index 000000000..b96b2c70f --- /dev/null +++ b/packages/ns-audit/files/ns_audit/models.py @@ -0,0 +1,739 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Shared JSON-compatible helpers for the NethSecurity audit analyzers.""" + +from __future__ import annotations + +import hashlib +import json +import re +import shlex +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass +from typing import Any + +JsonDict = dict[str, Any] + + +class AuditError(Exception): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message + + +@dataclass +class FileEvidence: + path: str + present: bool + readable: bool + size: int | None = None + truncated: bool = False + content: str | None = None + error: str | None = None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass +class CommandEvidence: + name: str + args: list[str] + found: bool + returncode: int | None = None + stdout: str = "" + stderr: str = "" + timed_out: bool = False + truncated: bool = False + parsed_json: object | None = None + error: str | None = None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +SEVERITY_ORDER = ("critical", "high", "medium", "low", "info") +SEVERITY_RANK = {severity: index for index, severity in enumerate(SEVERITY_ORDER)} +SEVERITY_RISK_POINTS = { + "critical": 100, + "high": 75, + "medium": 45, + "low": 20, + "info": 0, +} + +AREA_WEIGHTS = { + "identity": 25, + "firewall": 25, + "vpn": 20, + "ips": 15, + "logging": 15, +} +AREA_SCORE_GROUP = { + "identity": "identity", + "firewall": "firewall", + "vpn": "vpn", + "ips": "ips", + "logging": "logging", + "telemetry": "logging", +} +AREA_TITLES = { + "identity": "Identity and access control", + "firewall": "Firewall and exposed services", + "vpn": "VPN cryptography and remote access", + "ips": "IPS/IDS and threat detection", + "logging": "Logging and audit trail", + "telemetry": "Telemetry and security indicators", +} + +NIST_CONTROLS = { + "AC-2": "Account Management", + "AC-4": "Information Flow Enforcement", + "AC-6": "Least Privilege", + "AC-17": "Remote Access", + "AU-2": "Event Logging", + "AU-6": "Audit Record Review, Analysis, and Reporting", + "AU-9": "Protection of Audit Information", + "AU-11": "Audit Record Retention", + "CM-6": "Configuration Settings", + "CM-7": "Least Functionality", + "IA-2": "Identification and Authentication", + "IA-5": "Authenticator Management", + "RA-5": "Vulnerability Monitoring and Scanning", + "SC-7": "Boundary Protection", + "SC-8": "Transmission Confidentiality and Integrity", + "SC-13": "Cryptographic Protection", + "SI-3": "Malicious Code Protection", + "SI-4": "System Monitoring", +} + +ACN_NIS2_CATEGORIES = { + "identity_access_governance": "Identity and access governance", + "network_segmentation": "Network segmentation and boundary protection", + "secure_communications": "Secure communications and cryptography", + "threat_detection": "Vulnerability and threat detection", + "logging_monitoring": "Logging, monitoring, and incident evidence", + "risk_management": "Risk management and remediation tracking", +} + +TRUE_VALUES = {"1", "true", "yes", "on", "enabled", "enable", "active", "running"} +FALSE_VALUES = { + "0", + "false", + "no", + "off", + "disabled", + "disable", + "inactive", + "stopped", + "none", + "", +} +SECRET_FIELD_RE = re.compile( + r"(secret|password|passwd|private[_-]?key|preshared|token|hash|credential)", re.I +) +REDACTED_VALUES = { + "", + "redacted", + "", + "[redacted]", + "***", + "******", + "x", + "", + "hidden", +} +ADMIN_PORTS = {"22", "80", "443", "4443", "8000", "8080", "8443", "9090", "9091"} + + +def json_safe(value: Any) -> Any: + """Return a JSON-serializable copy of *value*.""" + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, Mapping): + return {str(key): json_safe(item) for key, item in value.items()} + if isinstance(value, set): + return [json_safe(item) for item in sorted(value, key=str)] + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [json_safe(item) for item in value] + if isinstance(value, bytes | bytearray): + return value.decode("utf-8", "replace") + return str(value) + + +def as_dict(value: Any) -> JsonDict: + if isinstance(value, Mapping): + return dict(value) + return {} + + +def as_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple | set): + return list(value) + return [value] + + +def string_list(value: Any) -> list[str]: + """Normalize UCI strings/lists into a list of non-empty strings.""" + result: list[str] = [] + for item in as_list(value): + if item is None: + continue + if isinstance(item, str): + parts = ( + re.split(r"[\s,]+", item.strip()) + if "," in item or " " in item.strip() + else [item.strip()] + ) + result.extend(part for part in parts if part) + else: + result.append(str(item)) + return result + + +def normalize_severity(severity: Any) -> str: + normalized = str(severity or "info").strip().lower() + return normalized if normalized in SEVERITY_RANK else "info" + + +def worst_severity(severities: Sequence[Any]) -> str: + normalized = [normalize_severity(severity) for severity in severities] + if not normalized: + return "info" + return min(normalized, key=lambda severity: SEVERITY_RANK[severity]) + + +def severity_counts(findings: Sequence[Mapping[str, Any]]) -> JsonDict: + counts = {severity: 0 for severity in SEVERITY_ORDER} + for finding in findings: + counts[normalize_severity(finding.get("severity"))] += 1 + return counts + + +def to_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, int | float): + return bool(value) + normalized = str(value).strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + return default + + +def option_value( + section: Mapping[str, Any], names: str | Sequence[str], default: Any = None +) -> Any: + """Read a UCI option from a section, accepting case and dash/underscore variants.""" + wanted = [names] if isinstance(names, str) else list(names) + if not isinstance(section, Mapping): + return default + lowered = { + str(key).lower().replace("-", "_"): value for key, value in section.items() + } + for name in wanted: + key = str(name) + if key in section: + return section[key] + normalized = key.lower().replace("-", "_") + if normalized in lowered: + return lowered[normalized] + return default + + +def section_type(section: Mapping[str, Any]) -> str: + return str( + option_value(section, (".type", "_type", "section_type", "type"), "") + ).strip() + + +def section_name(section: Mapping[str, Any]) -> str: + return str( + option_value(section, (".name", "_name", "section", "id", "name"), "") + ).strip() + + +def is_enabled_section(section: Mapping[str, Any], default: bool = True) -> bool: + if option_value(section, ("disabled", "disable")) is not None: + return not to_bool( + option_value(section, ("disabled", "disable")), default=False + ) + return to_bool( + option_value(section, ("enabled", "enable"), default), default=default + ) + + +def slugify(value: Any) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-") + return slug or "item" + + +def fingerprint(value: Any, prefix: str = "sha256") -> str: + text = str(value or "").strip() + if not text or is_redacted_value(text): + return "" + return f"{prefix}:{hashlib.sha256(text.encode()).hexdigest()[:16]}" + + +def is_redacted_value(value: Any) -> bool: + if value is None: + return True + normalized = str(value).strip().lower() + return ( + normalized in REDACTED_VALUES + or "redact" in normalized + or set(normalized) <= {"*", "x", "-"} + ) + + +def secret_field_name(name: Any) -> bool: + return bool(SECRET_FIELD_RE.search(str(name))) + + +def has_unredacted_secret(value: Any) -> bool: + if value is None: + return False + if isinstance(value, Mapping): + return any( + secret_field_name(key) and has_unredacted_secret(item) + for key, item in value.items() + ) + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return any(has_unredacted_secret(item) for item in value) + text = str(value).strip() + return bool(text and len(text) > 8 and not is_redacted_value(text)) + + +def _looks_like_uci_show(text: str) -> bool: + for raw_line in str(text or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + return False + left, _, _ = line.partition("=") + return left.count(".") >= 1 and not left.startswith( + ("config ", "option ", "list ") + ) + return False + + +def _uci_show_value(value: str) -> Any: + try: + parts = shlex.split(value, comments=False, posix=True) + except ValueError: + parts = [value.strip().strip("'\"")] + if not parts: + return "" + return parts[0] if len(parts) == 1 else parts + + +def parse_uci_show(text: str) -> list[JsonDict]: + """Parse `uci show ` output into UCI-like sections.""" + sections: dict[str, JsonDict] = {} + order: list[str] = [] + for raw_line in str(text or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + left, _, right = line.partition("=") + parts = left.split(".", 2) + if len(parts) < 2: + continue + section_ref = parts[1] + if section_ref not in sections: + sections[section_ref] = {".name": section_ref} + order.append(section_ref) + value = _uci_show_value(right) + if len(parts) == 2: + sections[section_ref][".type"] = str(value) + continue + option = parts[2] + if option in sections[section_ref]: + existing = sections[section_ref][option] + sections[section_ref][option] = as_list(existing) + as_list(value) + else: + sections[section_ref][option] = value + return [sections[section_ref] for section_ref in order] + + +def parse_uci_config(text: str) -> list[JsonDict]: + """Parse enough UCI syntax for audit normalization.""" + if _looks_like_uci_show(text): + return parse_uci_show(text) + sections: list[JsonDict] = [] + current: JsonDict | None = None + counters: dict[str, int] = {} + for raw_line in str(text or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + try: + parts = shlex.split(line, comments=True, posix=True) + except ValueError: + parts = line.split() + if not parts: + continue + keyword = parts[0] + if keyword == "config" and len(parts) >= 2: + current_type = parts[1] + index = counters.get(current_type, 0) + counters[current_type] = index + 1 + current_name = parts[2] if len(parts) > 2 else f"@{current_type}[{index}]" + current = {".type": current_type, ".name": current_name} + sections.append(current) + continue + if current is None or len(parts) < 3: + continue + key = parts[1] + value = " ".join(parts[2:]) + if keyword == "option": + current[key] = value + elif keyword == "list": + current.setdefault(key, []).append(value) + return sections + + +def _normalize_section(name: str, value: Any) -> JsonDict | None: + if not isinstance(value, Mapping): + return None + section = dict(value) + section.setdefault(".name", name) + if ".type" not in section: + for key in ("_type", "section_type", "type"): + if key in section: + section[".type"] = section[key] + break + return section + + +def _sections_from_package_data(data: Any) -> list[JsonDict]: + if isinstance(data, str): + return parse_uci_config(data) + if isinstance(data, Sequence) and not isinstance(data, str | bytes | bytearray): + return [dict(item) for item in data if isinstance(item, Mapping)] + if not isinstance(data, Mapping): + return [] + if isinstance(data.get("sections"), Sequence): + return [dict(item) for item in data["sections"] if isinstance(item, Mapping)] + for content_key in ("content", "raw", "text", "config"): + if isinstance(data.get(content_key), str): + return parse_uci_config(data[content_key]) + if any(str(key).startswith(".") for key in data): + return [dict(data)] + sections: list[JsonDict] = [] + for name, value in data.items(): + normalized = _normalize_section(str(name), value) + if normalized is not None: + sections.append(normalized) + return sections + + +def _file_entry_content(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, Mapping): + for key in ("content", "text", "data", "stdout"): + if isinstance(value.get(key), str): + return value[key] + return "" + + +def file_map(raw_snapshot: Mapping[str, Any]) -> JsonDict: + for key in ("files", "file_contents", "sources"): + value = raw_snapshot.get(key) + if isinstance(value, Mapping): + return dict(value) + return {} + + +def get_file(raw_snapshot: Mapping[str, Any], path: str) -> str: + files = file_map(raw_snapshot) + if path in files: + return _file_entry_content(files[path]) + for file_path, value in files.items(): + if str(file_path).rstrip("/").endswith(path): + return _file_entry_content(value) + return "" + + +def file_metadata(raw_snapshot: Mapping[str, Any], path: str) -> JsonDict: + files = file_map(raw_snapshot) + entry = files.get(path) + if entry is None: + for file_path, value in files.items(): + if str(file_path).rstrip("/").endswith(path): + entry = value + break + if isinstance(entry, Mapping): + return as_dict(entry.get("metadata") or entry.get("stat") or {}) + return {} + + +def files_matching(raw_snapshot: Mapping[str, Any], *needles: str) -> JsonDict: + files = file_map(raw_snapshot) + lowered_needles = [needle.lower() for needle in needles] + return { + str(path): value + for path, value in files.items() + if all(needle in str(path).lower() for needle in lowered_needles) + } + + +def uci_sections( + raw_snapshot: Mapping[str, Any], package: str, wanted_type: str | None = None +) -> list[JsonDict]: + data = None + for key in ("uci", "configs", "uci_configs"): + container = raw_snapshot.get(key) + if isinstance(container, Mapping) and package in container: + data = container[package] + break + if data is None: + data = get_file(raw_snapshot, f"/etc/config/{package}") + sections = _sections_from_package_data(data) + if wanted_type is not None: + sections = [ + section for section in sections if section_type(section) == wanted_type + ] + return [json_safe(section) for section in sections] + + +def _command_entry(raw_snapshot: Mapping[str, Any], candidates: Sequence[str]) -> Any: + commands = ( + raw_snapshot.get("commands") + or raw_snapshot.get("command_outputs") + or raw_snapshot.get("safe_commands") + ) + if not isinstance(commands, Mapping): + return None + normalized_candidates = [candidate.lower() for candidate in candidates] + for command, value in commands.items(): + command_text = str(command).lower() + if any( + candidate == command_text or candidate in command_text + for candidate in normalized_candidates + ): + return value + return None + + +def _json_from_text(text: str) -> Any: + try: + return json.loads(text) + except (TypeError, ValueError): + return None + + +def command_text(raw_snapshot: Mapping[str, Any], candidates: Sequence[str]) -> str: + entry = _command_entry(raw_snapshot, candidates) + if isinstance(entry, str): + return entry + if isinstance(entry, Mapping): + for key in ("stdout", "output", "text", "content"): + if isinstance(entry.get(key), str): + return entry[key] + return "" + + +def command_json(raw_snapshot: Mapping[str, Any], candidates: Sequence[str]) -> Any: + entry = _command_entry(raw_snapshot, candidates) + if isinstance(entry, Mapping): + for key in ("json", "data", "parsed"): + if key in entry: + return entry[key] + text = command_text(raw_snapshot, candidates) + parsed = _json_from_text(text) + return parsed if parsed is not None else entry + if isinstance(entry, str): + parsed = _json_from_text(entry) + return parsed if parsed is not None else {} + return {} + + +def service_state( + raw_snapshot: Mapping[str, Any], service_names: Sequence[str] +) -> JsonDict: + services = raw_snapshot.get("services") + if not isinstance(services, Mapping): + services = command_json( + raw_snapshot, ("ubus call service list", "service list") + ) + if not isinstance(services, Mapping): + return {"present": False, "enabled": False, "running": False, "instances": []} + names = [name.lower() for name in service_names] + result = {"present": False, "enabled": False, "running": False, "instances": []} + for service_name, service_data in services.items(): + if not any(name in str(service_name).lower() for name in names): + continue + data = as_dict(service_data) + result["present"] = True + result["enabled"] = result["enabled"] or to_bool( + option_value(data, ("enabled", "autostart")), default=False + ) + result["running"] = result["running"] or to_bool( + option_value(data, ("running", "active")), default=False + ) + instances = as_dict(data.get("instances")) + for instance_name, instance_data in instances.items(): + instance = as_dict(instance_data) + running = to_bool(instance.get("running"), default=False) or bool( + instance.get("pid") + ) + result["running"] = result["running"] or running + result["instances"].append( + { + "name": str(instance_name), + "running": running, + "pid_present": bool(instance.get("pid")), + } + ) + return json_safe(result) + + +def parse_passwd(content: str) -> list[JsonDict]: + accounts: list[JsonDict] = [] + for line in str(content or "").splitlines(): + if not line or line.startswith("#"): + continue + parts = line.split(":") + if len(parts) < 7: + continue + username, password_marker, uid, gid, gecos, home, shell = parts[:7] + try: + uid_number = int(uid) + except ValueError: + uid_number = -1 + try: + gid_number = int(gid) + except ValueError: + gid_number = -1 + accounts.append( + { + "username": username, + "uid": uid_number, + "gid": gid_number, + "gecos": gecos, + "home": home, + "shell": shell, + "password_marker": "set" + if password_marker and password_marker not in {"x", "*", "!"} + else password_marker, + "interactive_shell": shell + not in {"/bin/false", "/sbin/nologin", "/usr/sbin/nologin", "nologin"}, + } + ) + return accounts + + +def parse_group(content: str) -> JsonDict: + groups: JsonDict = {} + for line in str(content or "").splitlines(): + if not line or line.startswith("#"): + continue + parts = line.split(":") + if len(parts) < 4: + continue + name, _, gid, members = parts[:4] + member_list = [member for member in members.split(",") if member] + groups[name] = {"gid": gid, "members": member_list} + return groups + + +def iter_text(value: Any) -> list[str]: + texts: list[str] = [] + if isinstance(value, Mapping): + for key, item in value.items(): + texts.append(str(key)) + texts.extend(iter_text(item)) + elif isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + for item in value: + texts.extend(iter_text(item)) + elif value is not None: + texts.append(str(value)) + return texts + + +def contains_indicator(value: Any, indicators: Sequence[str]) -> bool: + lowered = [indicator.lower() for indicator in indicators] + return any( + indicator in text.lower() for text in iter_text(value) for indicator in lowered + ) + + +def build_finding( + finding_id: str, + title: str, + severity: str, + area: str, + component: str, + description: str, + evidence: Mapping[str, Any] | None, + remediation: str, + nist: Sequence[str] | None = None, + acn: Sequence[str] | None = None, +) -> JsonDict: + normalized_severity = normalize_severity(severity) + nist_controls = sorted(dict.fromkeys(nist or [])) + acn_categories = sorted(dict.fromkeys(acn or [])) + return json_safe( + { + "id": finding_id or slugify(f"{area}-{title}"), + "title": title, + "severity": normalized_severity, + "area": area, + "score_area": AREA_SCORE_GROUP.get(area, area), + "component": component, + "description": description, + "evidence": evidence or {}, + "remediation": remediation, + "compliance": { + "nist_800_53r5": nist_controls, + "acn_nis2": acn_categories, + }, + "risk_score": SEVERITY_RISK_POINTS[normalized_severity], + } + ) + + +def build_result( + area: str, inventory: Mapping[str, Any], findings: Sequence[Mapping[str, Any]] +) -> JsonDict: + counts = severity_counts(findings) + max_severity = worst_severity([finding.get("severity") for finding in findings]) + status = ( + "red" + if counts["critical"] or counts["high"] + else "yellow" + if counts["medium"] + else "green" + ) + return json_safe( + { + "inventory": {area: inventory}, + "findings": list(findings), + "summary": { + area: { + "title": AREA_TITLES.get(area, area.title()), + "status": status, + "max_severity": max_severity, + "finding_counts": counts, + "finding_count": sum(counts.values()), + } + }, + "compliance": {}, + } + ) diff --git a/packages/ns-audit/files/ns_audit/reporting/__init__.py b/packages/ns-audit/files/ns_audit/reporting/__init__.py new file mode 100644 index 000000000..98e977b9d --- /dev/null +++ b/packages/ns-audit/files/ns_audit/reporting/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Reporting helpers for the on-device NethSecurity audit tool.""" + +__all__ = [ + "DEFAULT_BUNDLE_NAME", + "DEFAULT_OUTPUT_NAME", + "EXPECTED_JSON_ARTIFACTS", + "build_log_artifacts", + "build_report_context", + "bundle_report_artifacts", + "find_secret_pattern_matches", + "load_json_artifact", + "materialize_log_artifacts", + "render_report", + "sanitize_log_artifacts", +] + + +def __getattr__(name): + if name in __all__: + from . import html + + return getattr(html, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/ns-audit/files/ns_audit/reporting/assets/audit-report.css b/packages/ns-audit/files/ns_audit/reporting/assets/audit-report.css new file mode 100644 index 000000000..04de92955 --- /dev/null +++ b/packages/ns-audit/files/ns_audit/reporting/assets/audit-report.css @@ -0,0 +1,553 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * SPDX-License-Identifier: GPL-2.0-only + * + * Inspired by the Nethesis design system. + */ + +@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap'); + +:root { + --primary: #0891b2; + --primary-dark: #0e7490; + --primary-700: #0e7490; + --primary-light: #cffafe; + --bg: #f8fafc; + --card: #ffffff; + --text: #111827; + --muted: #6b7280; + --border: #e5e7eb; + --sidebar-bg: #f9fafb; + --sidebar-text: #374151; + --sidebar-muted: #9ca3af; + --sidebar-w: 230px; + --green: #16a34a; + --green-bg: #dcfce7; + --yellow: #d97706; + --yellow-bg: #fef3c7; + --red: #dc2626; + --red-bg: #fee2e2; + --blue: #2563eb; + --blue-bg: #dbeafe; + --neutral: #6b7280; + --neutral-bg: #f3f4f6; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: 'Poppins', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 0.9rem; + line-height: 1.6; +} + +#layout { + display: flex; + min-height: 100vh; +} + +#sidebar { + position: sticky; + top: 0; + height: 100vh; + width: var(--sidebar-w); + flex-shrink: 0; + overflow-y: auto; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + padding: 0; + display: flex; + flex-direction: column; +} + +#sidebar .brand { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); + background: var(--card); +} + +#sidebar .brand-text { + color: var(--primary-dark); + font-weight: 700; + font-size: 1rem; +} + +#sidebar .brand-sub { + color: var(--muted); + font-size: 0.7rem; + margin-top: 2px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +#sidebar a { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + color: var(--sidebar-text); + text-decoration: none; + font-size: 0.82rem; + font-weight: 500; + border-left: 3px solid transparent; + border-radius: 0 6px 6px 0; + margin: 1px 4px 1px 0; + transition: all 0.12s; +} + +#sidebar a:hover, +#sidebar a.active { + background: #ecfeff; + color: var(--primary-dark); + border-left-color: var(--primary); +} + +#sidebar a:hover { + background: #f3f4f6; + color: #111827; +} + +#sidebar a.active { + background: #ecfeff; + color: var(--primary-dark); + border-left-color: var(--primary); + font-weight: 600; +} + +#main { + flex: 1; + min-width: 0; + max-width: 1120px; + padding: 32px 36px 48px; +} + +.report-header { + display: flex; + gap: 20px; + align-items: stretch; + margin-bottom: 18px; +} + +.header-info { + flex: 1; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid var(--border); + border-radius: 18px; + padding: 28px 32px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); +} + +.eyebrow { + color: var(--primary-dark); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + margin: 0 0 6px; + text-transform: uppercase; +} + +h1 { + font-size: clamp(1.6rem, 3.5vw, 2.6rem); + font-weight: 800; + line-height: 1.15; + margin: 0 0 10px; + color: var(--text); +} + +.header-meta { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +.header-meta strong { + color: var(--text); +} + +.score-card { + min-width: 208px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 18px; + padding: 24px 20px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); +} + +.score-card.green { border-color: var(--green); } +.score-card.yellow { border-color: var(--yellow); } +.score-card.red { border-color: var(--red); } + +.score-label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.score-card strong { + font-size: 3.5rem; + font-weight: 800; + line-height: 1; + color: var(--text); +} + +.score-card.green strong { color: var(--green); } +.score-card.yellow strong { color: var(--yellow); } +.score-card.red strong { color: var(--red); } + +.system-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + align-items: center; +} + +.badge-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); + padding: 10px 12px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04); +} + +.badge-label { + font-weight: 600; + color: var(--sidebar-text); +} + +.sub-badge { + background: #dcfce7; + color: #15803d; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + padding: 2px 10px; + letter-spacing: 0.04em; +} + +.ha-badge { + background: #dbeafe; + color: #1d4ed8; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + padding: 2px 10px; + letter-spacing: 0.04em; +} + +.report-section { + background: var(--card); + border: 1px solid var(--border); + border-radius: 18px; + padding: 28px 32px; + margin-bottom: 20px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); +} + +h2 { + font-size: 1.15rem; + font-weight: 700; + color: var(--text); + border-bottom: 2px solid var(--primary-light); + padding-bottom: 12px; + margin: 0 0 20px; +} + +h3 { + font-size: 0.95rem; + font-weight: 600; + margin: 20px 0 10px; + color: var(--text); +} + +h4 { + font-size: 0.85rem; + font-weight: 600; + margin: 0 0 6px; +} + +.metric-grid, +.area-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + margin: 0 0 24px; +} + +.metric, +.area-card { + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px; + background: var(--card); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.metric span { + color: var(--muted); + display: block; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.metric strong, +.area-score { + display: block; + font-size: 1.8rem; + font-weight: 800; + margin: 6px 0 0; +} + +.metric.severity-critical strong, +.metric.severity-high strong { color: var(--red); } +.metric.severity-medium strong { color: var(--yellow); } +.metric.severity-low strong { color: var(--blue); } + +.area-card h4 { font-size: 0.8rem; } +.area-card p { margin: 4px 0 0; font-size: 0.78rem; color: var(--muted); } + +.summary-list { padding-left: 20px; } +.summary-list li { margin-bottom: 6px; font-size: 0.88rem; } + +.severity-badge, +.status-pill { + border-radius: 999px; + display: inline-block; + font-size: 0.68rem; + font-weight: 700; + padding: 3px 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.status-pill.green, .green { background: var(--green-bg); color: var(--green); } +.status-pill.yellow, .yellow { background: var(--yellow-bg); color: var(--yellow); } +.status-pill.red, .red { background: var(--red-bg); color: var(--red); } +.status-pill.neutral, .neutral { background: var(--neutral-bg); color: var(--neutral); } + +.severity-badge.critical, .severity-badge.high { + background: var(--red-bg); color: var(--red); +} +.severity-badge.medium { + background: var(--yellow-bg); color: var(--yellow); +} +.severity-badge.low { + background: var(--blue-bg); color: var(--blue); +} +.severity-badge.info, +.severity-badge.informational { + background: var(--neutral-bg); color: var(--neutral); +} + +.finding-list { display: grid; gap: 14px; } + +.finding, +.remediation-item, +.inventory-section { + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 20px; + background: #fff; +} + +.finding.severity-critical, +.finding.severity-high, +.remediation-item.severity-critical, +.remediation-item.severity-high { + border-left: 5px solid var(--red); +} + +.finding.severity-medium, +.remediation-item.severity-medium { border-left: 5px solid var(--yellow); } +.finding.severity-low, +.remediation-item.severity-low { border-left: 5px solid var(--blue); } +.finding.severity-info, +.finding.severity-informational, +.remediation-item.severity-info, +.remediation-item.severity-informational { border-left: 5px solid var(--neutral); } + +.finding-heading { + align-items: flex-start; + display: flex; + gap: 14px; + justify-content: space-between; + margin-bottom: 8px; +} + +.finding-id { + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + margin: 0 0 4px; + text-transform: uppercase; +} + +.table-wrapper { overflow-x: auto; margin-top: 10px; } + +table { + border-collapse: collapse; + min-width: 100%; + font-size: 0.84rem; +} + +th, td { + border-bottom: 1px solid var(--border); + padding: 10px 14px; + text-align: left; + vertical-align: top; +} + +.table-value { + display: block; + white-space: pre-wrap; + line-height: 1.45; +} + +th { + background: #f8fafc; + color: #334155; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; +} + +tbody tr:hover { background: #f8fafc; } +tbody tr:nth-child(even) { background: #fcfcfd; } +tbody tr:nth-child(even):hover { background: #f3f4f6; } + +.remediation-list { padding-left: 0; list-style: none; } +.remediation-item { margin-bottom: 14px; } + +code, pre { + background: #f1f5f9; + border-radius: 6px; + color: #1e293b; + font-family: 'JetBrains Mono', 'Fira Mono', ui-monospace, monospace; + font-size: 0.8rem; +} + +code { padding: 2px 6px; word-break: break-all; } + +pre { + padding: 14px 16px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 8px 0 0; + border: 1px solid var(--border); + max-height: 300px; + overflow-y: auto; +} + +details > summary { + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; + color: var(--primary); + user-select: none; + margin-top: 8px; +} + +details[open] > summary { margin-bottom: 4px; } + +.config-list { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +.config-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + background: #fff; +} + +.config-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.config-card h4 { font-size: 0.88rem; margin: 0; color: var(--primary-dark); } + +.config-size { + font-size: 0.72rem; + color: var(--muted); + white-space: nowrap; +} + +.event-login_success { color: var(--green); font-weight: 700; } +.event-login_failure { color: var(--red); font-weight: 700; } +.event-logout { color: var(--neutral); font-weight: 600; } +.event-vpn_connect { color: var(--primary); font-weight: 600; } +.event-vpn_disconnect { color: var(--muted); font-weight: 600; } + +.source-badge { + background: var(--primary-light); + color: var(--primary-dark); + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + padding: 2px 8px; + text-transform: uppercase; +} + +.empty-state { + color: var(--muted); + font-style: italic; + font-size: 0.85rem; + padding: 12px 0; +} + +@media print { + #sidebar { display: none; } + #main { padding: 0; max-width: 100%; } + body { background: #fff; font-size: 0.82rem; } + .report-section { box-shadow: none; border-radius: 0; margin-bottom: 12px; } + .report-header { flex-direction: column; } + pre { max-height: none; } +} + +@media (max-width: 860px) { + #layout { flex-direction: column; } + #sidebar { + position: relative; + height: auto; + width: 100%; + padding-bottom: 10px; + } + #sidebar .brand { border-bottom: none; } + #sidebar a { + margin-right: 8px; + border-left: none; + border-radius: 8px; + } + #main { padding: 16px; } + .report-header { flex-direction: column; } + .score-card { min-width: auto; } + .config-list { grid-template-columns: 1fr; } +} diff --git a/packages/ns-audit/files/ns_audit/reporting/html.py b/packages/ns-audit/files/ns_audit/reporting/html.py new file mode 100644 index 000000000..aa149d43b --- /dev/null +++ b/packages/ns-audit/files/ns_audit/reporting/html.py @@ -0,0 +1,1173 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +"""Render sanitized NethSecurity audit JSON artifacts as an HTML report.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +import tarfile +from datetime import datetime, timezone +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from ns_audit.config import REPORT_BUNDLE_NAME + +DEFAULT_TEMPLATE_NAME = "audit-report.html.j2" +DEFAULT_CSS_NAME = "audit-report.css" +DEFAULT_OUTPUT_NAME = "audit-report.html" +DEFAULT_BUNDLE_NAME = REPORT_BUNDLE_NAME +EXPECTED_JSON_ARTIFACTS = ( + "raw_snapshot.json", + "inventory.json", + "findings.json", + "compliance_mapping.json", + "summary.json", +) +SEVERITY_ORDER = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + "info": 4, + "informational": 4, +} +SECRET_PATTERNS = { + "private_key_block": re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE + ), + "openvpn_static_key": re.compile( + r"-----BEGIN OpenVPN Static key V1-----", re.IGNORECASE + ), + "password_hash": re.compile(r"\$(?:1|2a|2b|2y|5|6|y|gy)\$[A-Za-z0-9./$]{20,}"), + "wireguard_private_key": re.compile( + r"\bprivate[_-]?key\b\s*[:=]\s*(?!\[?redacted\]?)[\"']?[A-Za-z0-9+/=]{32,}", + re.IGNORECASE, + ), + "wireguard_preshared_key": re.compile( + r"\bpreshared[_-]?key\b\s*[:=]\s*(?!\[?redacted\]?)[\"']?[A-Za-z0-9+/=]{32,}", + re.IGNORECASE, + ), + "api_token": re.compile( + r"\b(api[_-]?token|secret[_-]?token)\b\s*[:=]\s*(?!\[?redacted\]?)[\"']?[A-Za-z0-9._-]{24,}", + re.IGNORECASE, + ), +} + + +class ReportRenderingError(RuntimeError): + """Raised when sanitized report artifacts cannot be rendered safely.""" + + +def load_json_artifact(path: str | Path, default: Any | None = None) -> Any: + """Load a JSON artifact or return the provided default when it is missing.""" + artifact_path = Path(path) + if not artifact_path.exists(): + return {} if default is None else default + with artifact_path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def build_report_context( + input_dir: str | Path, + asset_dir: str | Path | None = None, + log_artifacts: list[dict[str, Any]] | None = None, + config_files: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a template context from sanitized JSON artifacts in input_dir.""" + report_dir = Path(input_dir) + assets = ( + Path(asset_dir) if asset_dir else Path(__file__).resolve().parent / "assets" + ) + raw_snapshot = load_json_artifact(report_dir / "raw_snapshot.json", {}) + summary = load_json_artifact(report_dir / "summary.json", {}) + inventory = load_json_artifact(report_dir / "inventory.json", {}) + findings_artifact = load_json_artifact(report_dir / "findings.json", {}) + compliance_artifact = load_json_artifact(report_dir / "compliance_mapping.json", {}) + findings = normalize_findings(findings_artifact) + source_artifacts = describe_source_artifacts(report_dir) + log_entries = ( + log_artifacts + if log_artifacts is not None + else sanitize_log_artifacts(build_log_artifacts(raw_snapshot)) + ) + api_audit = normalize_api_audit(inventory) + cfg_files = config_files if config_files is not None else build_config_files(raw_snapshot) + + return { + "generated_at": datetime.now(timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z"), + "summary": normalize_summary(summary, findings), + "hostname": _extract_hostname(inventory), + "subscription": _extract_subscription(raw_snapshot), + "ha_enabled": _extract_ha(raw_snapshot), + "inventory_sections": normalize_inventory_sections(inventory), + "api_audit": api_audit, + "auth_events": build_auth_events(raw_snapshot), + "config_changes": api_audit.get("config_changes", []), + "config_files": cfg_files, + "log_artifacts": log_entries, + "findings": findings, + "compliance_mappings": normalize_compliance_mappings(compliance_artifact), + "remediation_plan": build_remediation_plan(findings), + "source_artifacts": source_artifacts, + "snapshot_hash": source_artifacts.get("raw_snapshot.json", {}).get("sha256"), + "stylesheet": read_text(assets / DEFAULT_CSS_NAME), + } + + +def _extract_hostname(inventory: Any) -> str: + if not isinstance(inventory, dict): + return "" + identity = inventory.get("identity") or {} + if not isinstance(identity, dict): + return "" + platform = identity.get("platform") or {} + if not isinstance(platform, dict): + return "" + return str(platform.get("hostname", "") or "").strip() + + +def _extract_subscription(raw_snapshot: Any) -> dict[str, Any]: + if not isinstance(raw_snapshot, dict): + return {} + security_checks = raw_snapshot.get("security_checks") or {} + if not isinstance(security_checks, dict): + return {} + return dict(security_checks.get("subscription") or {}) + + +def _extract_ha(raw_snapshot: Any) -> bool: + if not isinstance(raw_snapshot, dict): + return False + uci = raw_snapshot.get("uci") or {} + if not isinstance(uci, dict): + return False + keepalived = uci.get("keepalived") or {} + sections = keepalived if isinstance(keepalived, list) else [] + if not sections and isinstance(keepalived, dict): + sections = list(keepalived.values()) + for section in sections: + if not isinstance(section, dict): + continue + name = str(section.get(".name", "") or section.get("name", "")).strip() + if name == "globals": + return str(section.get("enabled", "0")).strip() == "1" + return False + + +def render_report( + input_dir: str | Path, + output_path: str | Path | None = None, + template_dir: str | Path | None = None, + asset_dir: str | Path | None = None, + validate: bool = True, +) -> Path: + """Render audit-report.html from sanitized JSON artifacts.""" + report_dir = Path(input_dir) + destination = Path(output_path) if output_path else report_dir / DEFAULT_OUTPUT_NAME + output_dir = destination.parent + templates = ( + Path(template_dir) + if template_dir + else Path(__file__).resolve().parent / "templates" + ) + raw_snapshot = load_json_artifact(report_dir / "raw_snapshot.json", {}) + log_artifacts = build_log_artifacts(raw_snapshot) + materialize_log_artifacts(output_dir, log_artifacts) + config_files = build_config_files(raw_snapshot) + materialize_config_files(output_dir, config_files) + context = build_report_context( + report_dir, asset_dir, sanitize_log_artifacts(log_artifacts), + sanitize_config_files(config_files), + ) + + env = Environment( + loader=FileSystemLoader(str(templates)), + autoescape=select_autoescape(("html", "xml", "j2")), + trim_blocks=True, + lstrip_blocks=True, + ) + env.filters["humanize"] = humanize + env.filters["json_pretty"] = json_pretty + env.filters["severity_class"] = severity_class + env.filters["status_class"] = status_class + env.filters["format_value"] = format_value + + html = env.get_template(DEFAULT_TEMPLATE_NAME).render(**context) + if validate: + matches = find_secret_pattern_matches_in_text(html) + if matches: + raise ReportRenderingError( + f"refusing to write report with possible secret content: {matches}" + ) + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(html, encoding="utf-8") + return destination + + +def describe_source_artifacts(report_dir: Path) -> dict[str, dict[str, Any]]: + """Return existence, size, and SHA-256 metadata for expected JSON artifacts.""" + artifacts: dict[str, dict[str, Any]] = {} + for name in EXPECTED_JSON_ARTIFACTS: + path = report_dir / name + exists = path.exists() + artifacts[name] = { + "exists": exists, + "size": path.stat().st_size if exists else 0, + "sha256": sha256sum(path) if exists else None, + } + return artifacts + + +def normalize_summary(summary: Any, findings: list[dict[str, Any]]) -> dict[str, Any]: + data = summary if isinstance(summary, dict) else {} + counts = severity_counts(findings) + summary_counts = {} + for count_key in ("severity_counts", "finding_counts", "findings_by_severity"): + if isinstance(data.get(count_key), dict): + summary_counts = data[count_key] + break + for severity, count in summary_counts.items(): + counts[str(severity).lower()] = int(count or 0) + + score = first_present( + data, + ("score", "overall_score", "security_score", "compliance_score"), + default=None, + ) + if score is None and isinstance(data.get("summary"), dict): + score = first_present( + data["summary"], ("score", "overall_score", "security_score"), default=None + ) + + return { + "title": data.get("title", "NethSecurity Audit Report"), + "appliance": first_present( + data, + ("appliance", "hostname", "device", "system"), + default="NethSecurity appliance", + ), + "run_id": first_present( + data, ("run_id", "id", "audit_id"), default="not available" + ), + "score": score if score is not None else derive_score(counts), + "status": first_present( + data, ("status", "overall_status"), default=derive_status(counts) + ), + "finding_count": first_present( + data, + ("finding_count", "total_findings"), + default=sum(counts.values()) if summary_counts else len(findings), + ), + "severity_counts": counts, + "areas": normalize_area_scores( + first_present(data, ("areas", "area_scores", "scores"), default={}) + ), + "highlights": normalize_text_list( + first_present( + data, ("highlights", "executive_summary", "summary"), default=[] + ), + ), + } + + +def normalize_findings(findings_artifact: Any) -> list[dict[str, Any]]: + raw_findings = extract_sequence(findings_artifact, ("findings", "items", "results")) + normalized = [] + for index, raw in enumerate(raw_findings, start=1): + if not isinstance(raw, dict): + raw = {"description": raw} + severity = str( + first_present(raw, ("severity", "level", "risk"), default="info") + ).lower() + normalized.append( + { + "id": first_present( + raw, ("id", "code", "finding_id"), default=f"finding-{index}" + ), + "title": first_present( + raw, ("title", "name", "summary"), default=f"Finding {index}" + ), + "severity": severity, + "component": first_present( + raw, ("component", "area", "category", "scope"), default="General" + ), + "description": first_present( + raw, + ("description", "message", "details"), + default="No description provided.", + ), + "impact": first_present( + raw, ("impact", "risk_description"), default="" + ), + "remediation": first_present( + raw, ("remediation", "recommendation", "fix", "action"), default="" + ), + "evidence": normalize_evidence( + first_present( + raw, ("evidence", "evidence_refs", "references"), default=[] + ), + ), + "compliance": normalize_compliance_refs( + first_present( + raw, ("compliance", "controls", "mapping"), default=[] + ), + ), + "status": first_present(raw, ("status", "state"), default="open"), + "raw": raw, + } + ) + return sorted( + normalized, + key=lambda item: (SEVERITY_ORDER.get(item["severity"], 5), str(item["id"])), + ) + + +def normalize_inventory_sections(inventory: Any) -> list[dict[str, Any]]: + if not isinstance(inventory, dict): + return [] + + sections = [] + section_items = ( + inventory.get("sections") + if isinstance(inventory.get("sections"), list) + else None + ) + if section_items: + for section in section_items: + if isinstance(section, dict): + rows = normalize_rows(section.get("rows", section.get("items", []))) + sections.append( + { + "title": first_present( + section, ("title", "name", "id"), default="Inventory" + ), + "description": section.get("description", ""), + "columns": columns_for_rows(rows), + "rows": rows, + } + ) + return sections + + for name, value in inventory.items(): + if name == "updates": + sections.extend(_normalize_updates_inventory_sections(value)) + continue + if name == "logging": + sections.extend(_normalize_logging_inventory_sections(value)) + continue + sections.append(_build_inventory_section(humanize(name), value)) + return sections + + +def _build_inventory_section( + title: str, value: Any, description: str = "" +) -> dict[str, Any]: + rows = normalize_rows(value) + return { + "title": title, + "description": description, + "columns": columns_for_rows(rows), + "rows": rows, + } + + +def _normalize_updates_inventory_sections(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, dict): + return [_build_inventory_section("Updates", value)] + + section_definitions = ( + ( + "update", + "Updates", + "Firmware currency and automatic update policy for the appliance.", + ), + ( + "ha", + "High Availability", + "High availability configuration and current enablement status.", + ), + ( + "subscription", + "Subscription", + "NethSecurity subscription status, plan, and related support coverage.", + ), + ( + "certificate", + "TLS Certificate", + "Certificate evidence for the administrator UI, including ACME/Let's Encrypt state.", + ), + ) + sections = [] + for key, title, description in section_definitions: + if key in value: + sections.append( + _build_inventory_section(title, value.get(key), description) + ) + return sections or [_build_inventory_section("Updates", value)] + + +def _normalize_logging_inventory_sections(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, dict): + return [_build_inventory_section("Logging", value)] + + overview_keys = {"assessment", "storage_status", "controller", "remote_targets"} + overview = {key: value[key] for key in overview_keys if key in value} + details = {key: item for key, item in value.items() if key not in overview_keys} + sections = [] + if overview: + sections.append( + _build_inventory_section( + "Logging posture", + overview, + "Local retention and remote forwarding posture for audit evidence.", + ) + ) + if details: + sections.append( + _build_inventory_section( + "Logging details", + details, + "Log files, rotation evidence, and storage-backed retention details.", + ) + ) + return sections or [_build_inventory_section("Logging", value)] + + +def normalize_compliance_mappings(compliance_artifact: Any) -> list[dict[str, Any]]: + mapping_keys = ("mappings", "controls", "items", "compliance_mapping") + if isinstance(compliance_artifact, dict) and not any( + key in compliance_artifact for key in mapping_keys + ): + raw_mappings = flatten_mapping_dict(compliance_artifact) + else: + raw_mappings = extract_sequence(compliance_artifact, mapping_keys) + + mappings = [] + for index, raw in enumerate(raw_mappings, start=1): + if not isinstance(raw, dict): + raw = {"control": raw} + mappings.append( + { + "framework": first_present( + raw, ("framework", "standard"), default="Compliance" + ), + "control": first_present( + raw, ("control", "id", "code"), default=f"control-{index}" + ), + "title": first_present( + raw, ("title", "name", "description"), default="" + ), + "category": first_present( + raw, ("category", "domain", "area"), default="" + ), + "status": first_present( + raw, ("status", "state", "result"), default="not assessed" + ), + "findings": normalize_text_list( + first_present(raw, ("findings", "finding_ids", "gaps"), default=[]) + ), + "evidence": normalize_evidence( + first_present(raw, ("evidence", "evidence_refs"), default=[]) + ), + } + ) + return mappings + + +def build_remediation_plan(findings: list[dict[str, Any]]) -> list[dict[str, Any]]: + plan = [] + for finding in findings: + remediation = str(finding.get("remediation", "")).strip() + if remediation: + plan.append( + { + "priority": len(plan) + 1, + "finding_id": finding["id"], + "title": finding["title"], + "severity": finding["severity"], + "component": finding["component"], + "remediation": remediation, + "compliance": finding["compliance"], + } + ) + return plan + + +def _normalize_log_text(content: str) -> tuple[str, bool]: + lines = content.splitlines() + truncated = bool(lines and lines[-1] == "") + if truncated: + lines = lines[:-1] + return "\n".join(lines), truncated + + +def _log_archive_path(source_path: str) -> str: + if source_path == "logread": + return "logs/logread.txt" + normalized = source_path.lstrip("/") + return f"logs/{normalized}.txt" + + +def build_log_artifacts(raw_snapshot: Any) -> list[dict[str, Any]]: + if not isinstance(raw_snapshot, Mapping): + return [] + + logs = raw_snapshot.get("logs") + if not isinstance(logs, Mapping): + return [] + + # Use active_source to decide which log files to include (storage-aware). + # If active_source is "storage", only /mnt/data files are included. + # If active_source is "memory" (or not set), only /var/log files are included. + # logread output is always included as a memory fallback when no files are present. + active_source = str(logs.get("active_source", "memory")) + + candidates: list[dict[str, Any]] = [] + + log_files = logs.get("files") + if isinstance(log_files, Sequence) and not isinstance( + log_files, (str, bytes, bytearray) + ): + for entry in log_files: + if not isinstance(entry, Mapping): + continue + source_path = str(entry.get("path", "")).strip() + content = entry.get("content") + if not source_path or not isinstance(content, str) or not content.strip(): + continue + normalized_content, truncated_marker = _normalize_log_text(content) + if not normalized_content: + continue + candidates.append( + { + "source_path": source_path, + "source_kind": active_source, + "content": normalized_content, + "truncated": bool(entry.get("truncated")) or truncated_marker, + } + ) + + # Include logread only when no file-based logs were collected + if not candidates: + logread = logs.get("logread") + if isinstance(logread, Mapping): + stdout = logread.get("stdout") + if isinstance(stdout, str) and stdout.strip(): + content, truncated_marker = _normalize_log_text(stdout) + if content: + candidates.append( + { + "source_path": "logread", + "source_kind": "memory", + "content": content, + "truncated": bool(logread.get("truncated")) or truncated_marker, + } + ) + + return [ + { + "archive_path": _log_archive_path(c["source_path"]), + "display_path": c["source_path"], + "source_kind": c["source_kind"], + "source_paths": [c["source_path"]], + "size": len(c["content"].encode("utf-8")), + "sha256": hashlib.sha256(c["content"].encode("utf-8")).hexdigest(), + "truncated": c["truncated"], + "content": c["content"], + } + for c in candidates + ] + + +def sanitize_log_artifacts( + log_artifacts: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + sanitized = [] + for artifact in log_artifacts: + sanitized.append( + { + "archive_path": str(artifact.get("archive_path", "")), + "display_path": str(artifact.get("display_path", "")), + "source_kind": str(artifact.get("source_kind", "")), + "source_paths": [ + str(path) for path in artifact.get("source_paths", []) + ], + "size": int(artifact.get("size", 0) or 0), + "sha256": str(artifact.get("sha256", "")), + "truncated": bool(artifact.get("truncated")), + } + ) + return sanitized + + +def materialize_log_artifacts( + report_dir: Path, log_artifacts: Sequence[Mapping[str, Any]] +) -> list[Path]: + logs_dir = report_dir / "logs" + if logs_dir.exists(): + shutil.rmtree(logs_dir) + + written = [] + for artifact in log_artifacts: + archive_path = Path(str(artifact.get("archive_path", ""))) + content = artifact.get("content") + if not archive_path.parts or not isinstance(content, str): + continue + destination = report_dir / archive_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content, encoding="utf-8") + written.append(destination) + return written + + +def build_config_files(raw_snapshot: Any) -> list[dict[str, Any]]: + """Extract /etc/config/* entries from the sanitized snapshot for bundling.""" + if not isinstance(raw_snapshot, Mapping): + return [] + files = raw_snapshot.get("files") + if not isinstance(files, Mapping): + return [] + result = [] + for path, entry in sorted(files.items()): + if not str(path).startswith("/etc/config/"): + continue + if not isinstance(entry, Mapping): + continue + content = entry.get("content") + if not isinstance(content, str) or not content.strip(): + continue + name = str(path).removeprefix("/etc/config/") + archive_path = f"config/{name}" + preview_lines = content.splitlines()[:60] + result.append( + { + "name": name, + "source_path": str(path), + "archive_path": archive_path, + "size": len(content.encode("utf-8")), + "preview": "\n".join(preview_lines), + "content": content, + } + ) + return result + + +def sanitize_config_files(config_files: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Strip raw content from config file descriptors for template rendering.""" + return [ + { + "name": str(cf.get("name", "")), + "source_path": str(cf.get("source_path", "")), + "archive_path": str(cf.get("archive_path", "")), + "size": int(cf.get("size", 0) or 0), + "preview": str(cf.get("preview", "")), + } + for cf in config_files + ] + + +def materialize_config_files( + report_dir: Path, config_files: Sequence[Mapping[str, Any]] +) -> list[Path]: + """Write config file contents under report_dir/config/.""" + config_dir = report_dir / "config" + if config_dir.exists(): + shutil.rmtree(config_dir) + written = [] + for cf in config_files: + archive_path = Path(str(cf.get("archive_path", ""))) + content = cf.get("content") + if not archive_path.parts or not isinstance(content, str): + continue + destination = report_dir / archive_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content, encoding="utf-8") + written.append(destination) + return written + + +def build_auth_events(raw_snapshot: Any) -> dict[str, Any]: + """Extract structured authentication events from the victoria_logs snapshot key.""" + if not isinstance(raw_snapshot, Mapping): + return {"available": False, "events": [], "event_count": 0} + vl = raw_snapshot.get("victoria_logs") + if not isinstance(vl, Mapping): + return {"available": False, "events": [], "event_count": 0} + events = vl.get("events", []) + if not isinstance(events, list): + events = [] + return { + "available": bool(vl.get("available")), + "event_count": int(vl.get("event_count", len(events))), + "events": [ + { + "time": str(ev.get("time", ""))[:23].replace("T", " "), + "source": str(ev.get("source", "")), + "event_type": str(ev.get("event_type", "")), + "user": str(ev.get("user", "")), + "detail": str(ev.get("detail", ""))[:160], + } + for ev in events + if isinstance(ev, Mapping) + ], + "errors": vl.get("errors", {}), + } + + +def _archive_file(archive: tarfile.TarFile, path: Path, arcname: str) -> None: + stat = path.stat() + info = tarfile.TarInfo(arcname) + info.size = stat.st_size + info.mode = stat.st_mode & 0o777 + info.mtime = int(stat.st_mtime) + with path.open("rb") as handle: + archive.addfile(info, handle) + + +def bundle_report_artifacts( + report_dir: str | Path, + output_path: str | Path | None = None, + *, + validate: bool = True, +) -> Path: + report_dir = Path(report_dir) + render_report(report_dir, report_dir / DEFAULT_OUTPUT_NAME, validate=validate) + bundle_path = Path(output_path) if output_path else report_dir / DEFAULT_BUNDLE_NAME + bundle_path.parent.mkdir(parents=True, exist_ok=True) + + root_name = "audit-report" + excluded = {bundle_path.resolve()} + files = sorted( + path + for path in report_dir.rglob("*") + if path.is_file() and path.resolve() not in excluded + ) + + with tarfile.open(bundle_path, "w:gz") as archive: + for path in files: + arcname = str(Path(root_name) / path.relative_to(report_dir)) + _archive_file(archive, path, arcname) + + return bundle_path + + +def normalize_api_audit(inventory: Any) -> dict[str, Any]: + """Extract the API audit trail sub-section from the inventory dict.""" + if not isinstance(inventory, dict): + return {} + raw = inventory.get("api_audit", {}) + if not isinstance(raw, dict): + return {} + counts = raw.get("counts", {}) + return { + "available": bool(raw.get("api_log_lines", 0)), + "api_log_lines": raw.get("api_log_lines", 0), + "counts": counts, + "logins": raw.get("login_events", []), + "logouts": raw.get("logout_events", []), + "auth_failures": raw.get("auth_failure_events", []), + "config_changes": raw.get("config_change_events", []), + } + + +def find_secret_pattern_matches(paths: list[str | Path]) -> list[dict[str, Any]]: + matches = [] + for path in paths: + artifact = Path(path) + if artifact.is_dir(): + matches.extend( + find_secret_pattern_matches( + sorted(artifact.glob("*.json")) + sorted(artifact.glob("*.html")) + ), + ) + elif artifact.exists(): + matches.extend( + find_secret_pattern_matches_in_text( + artifact.read_text(encoding="utf-8", errors="replace"), + artifact, + ), + ) + return matches + + +def find_secret_pattern_matches_in_text( + text: str, path: Path | None = None +) -> list[dict[str, Any]]: + matches = [] + for name, pattern in SECRET_PATTERNS.items(): + for match in pattern.finditer(text): + matches.append( + { + "pattern": name, + "path": str(path) if path else None, + "offset": match.start(), + } + ) + return matches + + +def normalize_rows(value: Any) -> list[dict[str, str]]: + if isinstance(value, list): + return [normalize_row(item, index) for index, item in enumerate(value, start=1)] + if isinstance(value, dict): + if all(isinstance(item, dict) for item in value.values()): + return [ + {"name": str(key), **normalize_row(item, index)} + for index, (key, item) in enumerate(value.items(), start=1) + ] + return [ + {"property": humanize(key), "value": format_inventory_value(item)} + for key, item in value.items() + ] + if value in (None, ""): + return [] + return [{"property": "Value", "value": format_inventory_value(value)}] + + +def normalize_row(value: Any, index: int) -> dict[str, str]: + if isinstance(value, dict): + return { + str(key): format_inventory_value(item) for key, item in value.items() + } + return {"index": str(index), "value": format_inventory_value(value)} + + +def columns_for_rows(rows: list[dict[str, Any]]) -> list[str]: + columns: list[str] = [] + for row in rows: + for key in row: + if key not in columns: + columns.append(key) + return columns + + +def normalize_area_scores(value: Any) -> list[dict[str, Any]]: + if isinstance(value, list): + return [ + normalize_area_score(item, index) + for index, item in enumerate(value, start=1) + ] + if isinstance(value, dict): + return [ + normalize_area_score( + {"name": key, **(item if isinstance(item, dict) else {"score": item})}, + index, + ) + for index, (key, item) in enumerate(value.items(), start=1) + ] + return [] + + +def normalize_area_score(value: Any, index: int) -> dict[str, Any]: + if not isinstance(value, dict): + value = {"name": f"Area {index}", "score": value} + return { + "name": humanize( + first_present(value, ("name", "area", "category"), default=f"Area {index}") + ), + "score": first_present(value, ("score", "value"), default="not assessed"), + "status": first_present(value, ("status", "state"), default="not assessed"), + "weight": first_present(value, ("weight",), default=""), + } + + +def normalize_evidence(value: Any) -> list[dict[str, str]]: + evidence_items = extract_sequence( + value, ("evidence", "items", "refs", "references") + ) + normalized = [] + for index, item in enumerate(evidence_items, start=1): + if isinstance(item, dict): + normalized.append( + { + "id": str( + first_present( + item, ("id", "ref", "name"), default=f"evidence-{index}" + ) + ), + "source": str( + first_present(item, ("source", "file", "artifact"), default="") + ), + "detail": format_value( + first_present( + item, + ("detail", "description", "value", "line"), + default=item, + ), + ), + } + ) + else: + normalized.append( + {"id": f"evidence-{index}", "source": "", "detail": format_value(item)} + ) + return normalized + + +def normalize_compliance_refs(value: Any) -> list[str]: + refs = [] + for item in extract_sequence(value, ("controls", "items", "mappings")): + if isinstance(item, dict): + framework = first_present(item, ("framework", "standard"), default="") + control = first_present(item, ("control", "id", "code"), default="") + title = first_present(item, ("title", "name"), default="") + refs.append( + " ".join( + str(part) for part in (framework, control, title) if part + ).strip() + ) + else: + refs.append(str(item)) + return [ref for ref in refs if ref] + + +def flatten_mapping_dict(mapping: dict[str, Any]) -> list[dict[str, Any]]: + flattened = [] + for framework, controls in mapping.items(): + if isinstance(controls, dict): + for control, value in controls.items(): + if isinstance(value, dict): + flattened.append( + {"framework": framework, "control": control, **value} + ) + else: + flattened.append( + {"framework": framework, "control": control, "status": value} + ) + return flattened + + +def extract_sequence(value: Any, keys: tuple[str, ...]) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, dict): + for key in keys: + candidate = value.get(key) + if isinstance(candidate, list): + return candidate + if isinstance(candidate, dict): + return list(candidate.values()) + if "by_severity" in value and isinstance(value["by_severity"], dict): + return [ + item + for items in value["by_severity"].values() + for item in extract_sequence(items, keys) + ] + if value in (None, ""): + return [] + return [value] + + +def normalize_text_list(value: Any) -> list[str]: + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [format_value(item) for item in value] + if isinstance(value, dict): + return [f"{humanize(key)}: {format_value(item)}" for key, item in value.items()] + if value: + return [format_value(value)] + return [] + + +def severity_counts(findings: list[dict[str, Any]]) -> dict[str, int]: + counts = {severity: 0 for severity in ("critical", "high", "medium", "low", "info")} + for finding in findings: + severity = str(finding.get("severity", "info")).lower() + counts[severity if severity in counts else "info"] += 1 + return counts + + +def derive_status(counts: dict[str, int]) -> str: + if counts.get("critical", 0) or counts.get("high", 0): + return "red" + if counts.get("medium", 0): + return "yellow" + return "green" + + +def derive_score(counts: dict[str, int]) -> int: + deductions = ( + counts.get("critical", 0) * 25 + + counts.get("high", 0) * 15 + + counts.get("medium", 0) * 7 + + counts.get("low", 0) * 2 + ) + return max(0, 100 - deductions) + + +def first_present( + data: dict[str, Any], keys: tuple[str, ...], default: Any = None +) -> Any: + for key in keys: + if key in data and data[key] not in (None, ""): + return data[key] + return default + + +def format_value(value: Any) -> str: + if value is None: + return "not available" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, Mapping): + items = [ + f"{humanize(key)}: {format_value(item)}" for key, item in value.items() + ] + return "; ".join(items) if items else "{}" + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if not value: + return "[]" + rendered = [format_value(item) for item in value] + return ( + ", ".join(rendered) + if all(not isinstance(item, Mapping) for item in value) + else " | ".join(rendered) + ) + return str(value) + + +def format_inventory_value(value: Any) -> str: + if value is None: + return "not available" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, Mapping): + items = [] + for key, item in value.items(): + rendered = format_inventory_value(item) + if "\n" in rendered: + rendered = rendered.replace("\n", "\n ") + items.append(f"{humanize(key)}: {rendered}") + return "\n".join(items) if items else "{}" + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if not value: + return "[]" + rendered = [format_inventory_value(item) for item in value] + return ( + "\n\n".join(rendered) + if any(isinstance(item, Mapping) for item in value) + else ", ".join(rendered) + ) + return str(value) + + +def humanize(value: Any) -> str: + return str(value).replace("_", " ").replace("-", " ").strip().title() + + +def json_pretty(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + +def severity_class(value: Any) -> str: + severity = str(value).lower() + return severity if severity in SEVERITY_ORDER else "info" + + +def status_class(value: Any) -> str: + status = str(value).lower() + if status in ("pass", "passed", "ok", "green", "compliant", "implemented"): + return "green" + if status in ("warn", "warning", "yellow", "partial", "partially implemented"): + return "yellow" + if status in ("fail", "failed", "red", "non-compliant", "not implemented"): + return "red" + return "neutral" + + +def sha256sum(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render or validate NethSecurity audit report artifacts." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + render_parser = subparsers.add_parser( + "render", help="render audit-report.html from JSON artifacts" + ) + render_parser.add_argument( + "--input", + required=True, + help="directory containing sanitized audit JSON artifacts", + ) + render_parser.add_argument( + "--output", help="output HTML path; defaults to INPUT/audit-report.html" + ) + render_parser.add_argument( + "--template-dir", help="directory containing audit-report.html.j2" + ) + render_parser.add_argument( + "--asset-dir", help="directory containing audit-report.css" + ) + render_parser.add_argument( + "--no-validation", + action="store_true", + help="skip generated HTML secret-pattern validation", + ) + + validate_parser = subparsers.add_parser( + "validate", + help="scan generated HTML/JSON artifacts for known secret patterns", + ) + validate_parser.add_argument( + "paths", nargs="+", help="files or directories to validate" + ) + return parser.parse_args(argv) + + +def main() -> int: + args = parse_args(sys.argv[1:]) + if args.command == "render": + output = render_report( + input_dir=args.input, + output_path=args.output, + template_dir=args.template_dir, + asset_dir=args.asset_dir, + validate=not args.no_validation, + ) + print(json.dumps({"output": str(output), "status": "ok"})) + return 0 + + matches = find_secret_pattern_matches([Path(path) for path in args.paths]) + print( + json.dumps( + {"status": "failed" if matches else "ok", "matches": matches}, + sort_keys=True, + ) + ) + return 1 if matches else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/ns-audit/files/ns_audit/reporting/templates/audit-report.html.j2 b/packages/ns-audit/files/ns_audit/reporting/templates/audit-report.html.j2 new file mode 100644 index 000000000..03fed338f --- /dev/null +++ b/packages/ns-audit/files/ns_audit/reporting/templates/audit-report.html.j2 @@ -0,0 +1,492 @@ +{# + # Copyright (C) 2026 Nethesis S.r.l. + # SPDX-License-Identifier: GPL-2.0-only + #} + + + + + + {{ summary.title }} + + + +
+ + + +
+ + +
+
+

NethSecurity on-device audit

+

{{ summary.title }}

+

Generated {{ generated_at }} · Host: {{ hostname or summary.appliance }}{% if subscription and subscription.active %} · {{ subscription.plan or "Subscribed" }}{% endif %}{% if ha_enabled %} · HA Active{% endif %}

+
+
+ Overall score + {{ summary.score }} + {{ summary.status | humanize }} +
+
+ +
+
+ Subscription + {% if subscription and subscription.active %} + {{ subscription.plan or "Subscribed" }} + {% else %} + Not active + {% endif %} +
+
+ High availability + {% if ha_enabled %} + HA Active + {% else %} + Not active + {% endif %} +
+
+ + +
+

Executive Summary

+
+
+ Total findings + {{ summary.finding_count }} +
+ {% for severity, count in summary.severity_counts.items() %} +
+ {{ severity | humanize }} + {{ count }} +
+ {% endfor %} +
+ + {% if summary.highlights %} +

Key observations

+
    + {% for highlight in summary.highlights %} +
  • {{ highlight }}
  • + {% endfor %} +
+ {% endif %} + + {% if summary.areas %} +

Security areas

+
+ {% for area in summary.areas %} +
+

{{ area.name }}

+

{{ area.score }}

+

{{ area.status | humanize }}{% if area.weight %} · Weight {{ area.weight }}{% endif %}

+
+ {% endfor %} +
+ {% endif %} +
+ + +
+

Configuration Inventory

+ {% if inventory_sections %} + {% for section in inventory_sections %} +
+

{{ section.title }}

+ {% if section.description %}

{{ section.description }}

{% endif %} + {% if section.rows %} +
+ + + + {% for column in section.columns %} + + {% endfor %} + + + + {% for row in section.rows %} + + {% for column in section.columns %} + + {% endfor %} + + {% endfor %} + +
{{ column | humanize }}
{{ row.get(column, "") }}
+
+ {% else %} +

No inventory evidence available for this section.

+ {% endif %} +
+ {% endfor %} + {% else %} +

No configuration inventory artifact was found.

+ {% endif %} +
+ + +
+

Authentication Events

+

Login, logout, and VPN connection events sourced from victoria-logs (structured log store, 30-day window). Covers SSH (dropbear), web UI (nethsecurity-api), and VPN (openvpn/ipsec).

+ + {% if auth_events and auth_events.available %} +

{{ auth_events.event_count }} events collected.

+ {% if auth_events.errors %} +

Some sources could not be queried: {{ auth_events.errors | format_value }}

+ {% endif %} + + {% if auth_events.events %} +
+ + + + + + + + + + + + {% for ev in auth_events.events | reverse %} + + + + + + + + {% endfor %} + +
Time (UTC)SourceEventUserDetail
{{ ev.time }}{{ ev.source }}{{ ev.event_type | humanize }}{% if ev.user %}{{ ev.user }}{% else %}—{% endif %}{{ ev.detail }}
+
+ {% else %} +

Victoria-logs is available but no qualifying auth events were found in the last 30 days.

+ {% endif %} + + {% else %} +

Victoria-logs is not available or returned no data. Ensure the service is running at http://127.0.0.1:9428.

+ {% endif %} +
+ + +
+

API Audit Trail

+

Login, logout, and authentication events extracted from nethsecurity-api entries in the system log (/var/log/messages or persistent storage).

+ + {% if api_audit and api_audit.available %} +

+ API log lines sampled: {{ api_audit.api_log_lines }} +  ·  Logins: {{ api_audit.counts.get("logins", 0) }} +  ·  Logouts: {{ api_audit.counts.get("logouts", 0) }} +  ·  Auth failures: {{ api_audit.counts.get("auth_failures", 0) }} +  ·  Config changes: {{ api_audit.counts.get("config_changes", 0) }} +

+ + {% if api_audit.logins %} +

Login events

+
+ + + + {% for ev in api_audit.logins %} + + + + + + {% endfor %} + +
TimestampUserSource IP
{{ ev.get("timestamp", "") }}{{ ev.get("user", "") }}{{ ev.get("source_ip", "") }}
+
+ {% endif %} + + {% if api_audit.logouts %} +

Logout events

+
+ + + + {% for ev in api_audit.logouts %} + + + + + {% endfor %} + +
TimestampUser
{{ ev.get("timestamp", "") }}{{ ev.get("user", "") }}
+
+ {% endif %} + + {% if api_audit.auth_failures %} +

Authentication failures

+
+ + + + {% for ev in api_audit.auth_failures %} + + + + + + {% endfor %} + +
TimestampUserReason
{{ ev.get("timestamp", "") }}{{ ev.get("user", "—") }}{{ ev.get("reason", "") }}
+
+ {% endif %} + + {% else %} +

No nethsecurity-api log entries were found in the sampled log data. Ensure rsyslog is active and the log buffer retains recent activity.

+ {% endif %} +
+ + +
+

Configuration Changes

+

All ns.commit operations found in the sampled log — each entry records the user, timestamp, and the exact UCI change applied to the system.

+ + {% if config_changes %} +

{{ config_changes | length }} change event(s) found.

+
+ + + + + + + + + + + {% for ev in config_changes %} + + + + + + + {% endfor %} + +
TimestampUserChange descriptionRaw payload
{{ ev.get("timestamp", "") }}{{ ev.get("user", "") }}{{ ev.get("description", "") }} + {% if ev.get("raw_payload") %} +
+ Show payload +
{{ ev.get("raw_payload", "") }}
+
+ {% endif %} +
+
+ {% else %} +

No ns.commit configuration change events were found in the sampled log data.

+ {% endif %} +
+ + +
+

Gap Analysis & Compliance Mapping

+ {% if findings %} +
+ {% for finding in findings %} +
+
+
+

{{ finding.id }} · {{ finding.component }}

+

{{ finding.title }}

+
+ {{ finding.severity | humanize }} +
+

{{ finding.description }}

+ {% if finding.impact %}

Impact: {{ finding.impact }}

{% endif %} + {% if finding.compliance %} +

Mapped controls: {{ finding.compliance | join("; ") }}

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No findings were reported by the analyzer.

+ {% endif %} + + {% if compliance_mappings %} +

Compliance control coverage

+
+ + + + + + + + + + + + {% for mapping in compliance_mappings %} + + + + + + + + {% endfor %} + +
FrameworkControlCategoryStatusRelated findings
{{ mapping.framework }}{{ mapping.control }}{% if mapping.title %}
{{ mapping.title }}{% endif %}
{{ mapping.category }}{{ mapping.status | humanize }}{{ mapping.findings | join(", ") }}
+
+ {% endif %} +
+ + +
+

Actionable Remediation Plan

+ {% if remediation_plan %} +
    + {% for item in remediation_plan %} +
  1. +

    {{ item.title }}

    +

    {{ item.finding_id }} · {{ item.component }} · {{ item.severity | humanize }}

    +

    {{ item.remediation }}

    + {% if item.compliance %}

    Controls: {{ item.compliance | join("; ") }}

    {% endif %} +
  2. + {% endfor %} +
+ {% else %} +

No remediation actions are required or no remediation data was provided.

+ {% endif %} +
+ + +
+

Configuration Navigator

+

All /etc/config/* files collected at audit time, with secrets and keys redacted. Download the full file via the link or expand the preview below.

+ + {% if config_files %} +
+ {% for cf in config_files %} +
+
+

{{ cf.name }}

+ {{ cf.size }} B +
+
+ Preview +
{{ cf.preview }}
+
+
+ {% endfor %} +
+ {% else %} +

No /etc/config files were found in the audit snapshot.

+ {% endif %} +
+ + +
+

Collected Logs

+

Log files copied into the report bundle from the active log source (persistent storage if mounted, otherwise in-memory ring buffer). Open the file link after extracting the tar.gz archive.

+ + {% if log_artifacts %} +
+ + + + + + + + + + + + {% for log in log_artifacts %} + + + + + + + + {% endfor %} + +
Source pathSourceBytesTruncatedOpen
{{ log.source_paths | join("; ") }}{{ log.source_kind }}{{ log.size }}{{ "yes" if log.truncated else "no" }}{{ log.archive_path }}
+
+ {% else %} +

No log files were available to include in the report bundle.

+ {% endif %} +
+ + +
+

Evidence Appendix

+

Evidence is generated locally from sanitized JSON artifacts. The report does not include private keys, password hashes, tokens, or shared secrets.

+ {% if snapshot_hash %} +

Sanitized raw snapshot SHA-256: {{ snapshot_hash }}

+ {% endif %} + +

Artifact integrity

+
+ + + + + + + + + + + {% for name, artifact in source_artifacts.items() %} + + + + + + + {% endfor %} + +
ArtifactPresentSize (B)SHA-256
{{ name }}{{ "yes" if artifact.exists else "no" }}{{ artifact.size }}{% if artifact.sha256 %}{{ artifact.sha256[:20] }}…{% endif %}
+
+ + {% if findings %} +

Finding evidence references

+ {% for finding in findings %} +
+

{{ finding.id }} · {{ finding.title }}

+ {% if finding.evidence %} +
    + {% for evidence in finding.evidence %} +
  • {{ evidence.id }}{% if evidence.source %} from {{ evidence.source }}{% endif %}: {{ evidence.detail }}
  • + {% endfor %} +
+ {% else %} +

No evidence reference provided.

+ {% endif %} +
+ {% endfor %} + {% endif %} +
+ +
+
+ + diff --git a/packages/ns-audit/files/ns_audit/sanitize.py b/packages/ns-audit/files/ns_audit/sanitize.py new file mode 100644 index 000000000..20108414b --- /dev/null +++ b/packages/ns-audit/files/ns_audit/sanitize.py @@ -0,0 +1,119 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from typing import Any + +REDACTED = "" + +_PRIVATE_KEY_BLOCK_RE = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", + re.DOTALL, +) +_OPENSSH_PRIVATE_KEY_RE = re.compile( + r"-----BEGIN OPENSSH PRIVATE KEY-----.*?-----END OPENSSH PRIVATE KEY-----", + re.DOTALL, +) +_INLINE_SECRET_BLOCK_RE = re.compile( + r"<(?Pkey|tls-auth|tls-crypt|secret)>.*?", + re.DOTALL | re.IGNORECASE, +) +_UCI_SECRET_RE = re.compile( + r"(?im)^(?P\s*(?:option|list)\s+" + r"(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|key|shared[_-]?secret)" + r"\s+)(?P['\"]).*?(?P=quote)\s*$", +) +_KEY_VALUE_SECRET_RE = re.compile( + r"(?im)^(?P\s*[^#\n]*\b" + r"(?:password|passwd|secret|token|credential|api[_-]?key|private[_-]?key|preshared[_-]?key|psk)" + r"\b\s*[:=]\s*).+$", +) +_WIREGUARD_SECRET_RE = re.compile( + r"(?im)^(?P\s*(?:private key|preshared key):\s*).+$" +) +_AUTH_HEADER_RE = re.compile( + r"(?im)^(?P\s*authorization:\s*(?:bearer|basic)\s+).+$" +) +_PASSWORD_HASH_RE = re.compile(r"\$(?:1|2a|2b|2y|5|6|y|gy)\$[A-Za-z0-9./$]{20,}") + +_SENSITIVE_KEYWORDS = ( + "password", + "passwd", + "passphrase", + "secret", + "token", + "credential", + "private_key", + "private-key", + "preshared", + "psk", + "api_key", + "api-key", + "apikey", + "auth_key", + "shared_key", + "key_material", +) + + +def _is_sensitive_key(key: object) -> bool: + key_text = str(key).lower().replace("-", "_") + if key_text == "key": + return True + return any(keyword.replace("-", "_") in key_text for keyword in _SENSITIVE_KEYWORDS) + + +def redact_text(value: str) -> str: + redacted = _OPENSSH_PRIVATE_KEY_RE.sub(REDACTED, value) + redacted = _PRIVATE_KEY_BLOCK_RE.sub(REDACTED, redacted) + redacted = _INLINE_SECRET_BLOCK_RE.sub( + lambda match: f"<{match.group('tag')}>{REDACTED}", + redacted, + ) + redacted = _UCI_SECRET_RE.sub( + lambda match: f"{match.group('prefix')}{match.group('quote')}{REDACTED}{match.group('quote')}", + redacted, + ) + redacted = _KEY_VALUE_SECRET_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", redacted + ) + redacted = _WIREGUARD_SECRET_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", redacted + ) + redacted = _AUTH_HEADER_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", redacted + ) + return _PASSWORD_HASH_RE.sub(REDACTED, redacted) + + +def sanitize_value(value: Any, key: object | None = None) -> Any: + # Only redact string values under sensitive key names. + # Booleans, integers, and None are never actual secrets — preserve them as-is. + if key is not None and _is_sensitive_key(key) and isinstance(value, str) and value: + return REDACTED + if isinstance(value, str): + return redact_text(value) + if isinstance(value, Mapping): + return { + item_key: sanitize_value(item_value, item_key) + for item_key, item_value in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray | str): + return [sanitize_value(item) for item in value] + return value + + +def sanitize_snapshot(snapshot: Mapping[str, Any]) -> dict[str, Any]: + sanitized = sanitize_value(snapshot) + if isinstance(sanitized, dict): + collector = sanitized.get("collector") + if isinstance(collector, dict): + collector["redaction"] = "secret patterns redacted before persistence" + return sanitized + return {"snapshot": sanitized} diff --git a/packages/ns-openvpn/files/80-save-connection b/packages/ns-openvpn/files/80-save-connection index 531c5e8ab..e0495fe1d 100755 --- a/packages/ns-openvpn/files/80-save-connection +++ b/packages/ns-openvpn/files/80-save-connection @@ -9,6 +9,7 @@ import re import os import sys import sqlite3 +import syslog from euci import EUci from nethsec import users @@ -37,6 +38,8 @@ instance = re.sub(r'^openvpn-|\.conf$', '', config_path) virtual_ip_addr = os.environ.get("ifconfig_pool_remote_ip") uci = EUci() +syslog.openlog("openvpn-connect", syslog.LOG_PID, syslog.LOG_AUTHPRIV) + # The OpenVPN server sets the virtual IP address of the client in the environment variable ifconfig_pool_remote_ip, # still this value is not reliable in case of an IP reservation. try: @@ -63,6 +66,10 @@ try: start_time = int(os.environ.get('time_unix')) c.execute("INSERT INTO connections (common_name, virtual_ip_addr, remote_ip_addr, start_time) VALUES (?, ?, ?, ?)", (common_name, virtual_ip_addr, remote_ip_addr, start_time)) + syslog.syslog( + syslog.LOG_INFO, + f"event=connect instance={instance} user={common_name} remote_ip={remote_ip_addr} virtual_ip={virtual_ip_addr} start_time={start_time}" + ) conn.commit() conn.close() diff --git a/packages/ns-openvpn/files/80-save-disconnection b/packages/ns-openvpn/files/80-save-disconnection index 42ba01926..8a86ebb50 100755 --- a/packages/ns-openvpn/files/80-save-disconnection +++ b/packages/ns-openvpn/files/80-save-disconnection @@ -8,6 +8,7 @@ import os import sys import sqlite3 +import syslog from euci import EUci from nethsec import users @@ -20,6 +21,9 @@ def get_db_path(u, instance): uci = EUci() conn = sqlite3.connect(get_db_path(uci, sys.argv[1])) c = conn.cursor() +instance = sys.argv[1] + +syslog.openlog("openvpn-disconnect", syslog.LOG_PID, syslog.LOG_AUTHPRIV) env = os.environ common_name = env.get('common_name') @@ -29,6 +33,8 @@ start_time = int(env.get('time_unix', '0')) duration = int(env.get('time_duration', '0')) bytes_received = int(env.get('bytes_received', '0')) bytes_sent = int(env.get('bytes_sent', '0')) +virtual_ip_addr = env.get('ifconfig_pool_remote_ip', '') +remote_ip_addr = env.get('untrusted_ip', '') # Update connection data c.execute("UPDATE connections SET duration=?, bytes_received=?, bytes_sent=? WHERE common_name=? and start_time=?", (duration, bytes_received, bytes_sent, common_name, start_time)) @@ -47,8 +53,6 @@ if c.rowcount == 0: pass if enabled: - virtual_ip_addr = env.get('ifconfig_pool_remote_ip', '') - remote_ip_addr = env.get('untrusted_ip', '') c.execute( "INSERT INTO connections (common_name, virtual_ip_addr, remote_ip_addr, start_time, duration, bytes_received, bytes_sent) " "VALUES (?, ?, ?, ?, ?, ?, ?)", @@ -57,4 +61,8 @@ if c.rowcount == 0: conn.commit() conn.close() +syslog.syslog( + syslog.LOG_INFO, + f"event=disconnect instance={instance} user={common_name} remote_ip={remote_ip_addr} virtual_ip={virtual_ip_addr} start_time={start_time} duration={duration} bytes_received={bytes_received} bytes_sent={bytes_sent}" +) sys.exit(0) diff --git a/packages/ns-openvpn/files/openvpn-local-auth b/packages/ns-openvpn/files/openvpn-local-auth index 9c0f61aa1..eef3ae9f6 100755 --- a/packages/ns-openvpn/files/openvpn-local-auth +++ b/packages/ns-openvpn/files/openvpn-local-auth @@ -12,6 +12,7 @@ import os import re +import syslog import sys from euci import EUci from nethsec import users @@ -21,29 +22,46 @@ password = os.environ.get("password") config_path = os.environ.get("config") instance = re.sub(r'^openvpn-|\.conf$', '', config_path) +remote_ip = os.environ.get("untrusted_ip", "unknown") + +syslog.openlog("openvpn-auth", syslog.LOG_PID, syslog.LOG_AUTHPRIV) + + +def audit(outcome, reason=None): + message = f"event=auth outcome={outcome} instance={instance} user={username} remote_ip={remote_ip}" + if reason: + message = f"{message} reason={reason}" + priority = syslog.LOG_INFO if outcome == "success" else syslog.LOG_WARNING + syslog.syslog(priority, message) uci = EUci() try: db = uci.get("openvpn", instance, "ns_user_db") except: # user db not set + audit("failure", "user_db_not_set") sys.exit(5) user = users.get_user_by_name(uci, username, db) if user is None: # user not found + audit("failure", "user_not_found") sys.exit(4) if not "openvpn_enabled" in user or user["openvpn_enabled"] != "1": # user not enabled + audit("failure", "user_disabled") sys.exit(3) if "password" not in user: # password not set + audit("failure", "password_not_set") sys.exit(2) if users.check_password(password, user["password"]): + audit("success") sys.exit(0) else: # password do not match + audit("failure", "password_mismatch") sys.exit(1) diff --git a/packages/ns-openvpn/files/openvpn-remote-auth b/packages/ns-openvpn/files/openvpn-remote-auth index 95341d36f..c177d83bd 100755 --- a/packages/ns-openvpn/files/openvpn-remote-auth +++ b/packages/ns-openvpn/files/openvpn-remote-auth @@ -13,6 +13,7 @@ import re import os import sys +import syslog import subprocess from euci import EUci from nethsec import users @@ -21,11 +22,21 @@ def debug(msg): if os.environ.get("debug", False): print(f'[DEBUG] {msg}', file=sys.stderr) + +def audit(outcome, reason=None): + message = f"event=auth outcome={outcome} instance={instance} user={username} remote_ip={remote_ip}" + if reason: + message = f"{message} reason={reason}" + priority = syslog.LOG_INFO if outcome == "success" else syslog.LOG_WARNING + syslog.syslog(priority, message) + username = os.environ.get("username") password = os.environ.get("password") config_path = os.environ.get("config") +remote_ip = os.environ.get("untrusted_ip", "unknown") instance = re.sub(r'^openvpn-|\.conf$', '', config_path) +syslog.openlog("openvpn-auth", syslog.LOG_PID, syslog.LOG_AUTHPRIV) debug(f"Instance: {instance}") ldap = {} @@ -36,6 +47,7 @@ try: except: # user db not set or not found debug("Error: user db not found") + audit("failure", "user_db_not_found") sys.exit(5) debug(f"Username: {username}") @@ -45,11 +57,13 @@ debug(f"User: {user}") if user is None: # user not found debug(f"Error: user '{user}' not found") + audit("failure", "user_not_found") sys.exit(4) if not "openvpn_enabled" in user or user["openvpn_enabled"] != "1": # user not enabled debug(f"Error: user '{user}' disabled") + audit("failure", "user_disabled") sys.exit(3) uri = ldap.get("uri", "") @@ -64,6 +78,7 @@ user_bind_dn = ldap.get("user_bind_dn") if not uri or not base_dn: # no info to connect ldap db debug(f"Error: invalid LDAP URI or base DN") + audit("failure", "invalid_ldap_configuration") sys.exit(2) if user_bind_dn: @@ -94,7 +109,9 @@ debug(f"Command: LDAPTLS_REQCERT={tls_reqcert} " + " ".join(ldapsearch_command)) try: subprocess.run(ldapsearch_command, env=env, check=True, capture_output=True) debug("Success") + audit("success") sys.exit(0) except Exception as e: print(e, file=sys.stderr) + audit("failure", "ldap_authentication_failed") sys.exit(1)