lanfence 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lanfence/__init__.py +6 -0
- lanfence/alerts.py +134 -0
- lanfence/allowlist.py +101 -0
- lanfence/cli.py +394 -0
- lanfence/config.py +130 -0
- lanfence/data/oui_vendors.txt +148 -0
- lanfence/data/rogue_signatures.yaml +106 -0
- lanfence/db.py +182 -0
- lanfence/engine.py +202 -0
- lanfence/fingerprint.py +164 -0
- lanfence/fsutil.py +45 -0
- lanfence/logging_config.py +48 -0
- lanfence/models.py +128 -0
- lanfence/netutil.py +49 -0
- lanfence/report.py +189 -0
- lanfence/sanitize.py +48 -0
- lanfence/scanner.py +243 -0
- lanfence/vendor.py +52 -0
- lanfence-0.1.0.dist-info/METADATA +272 -0
- lanfence-0.1.0.dist-info/RECORD +24 -0
- lanfence-0.1.0.dist-info/WHEEL +5 -0
- lanfence-0.1.0.dist-info/entry_points.txt +2 -0
- lanfence-0.1.0.dist-info/licenses/LICENSE +21 -0
- lanfence-0.1.0.dist-info/top_level.txt +1 -0
lanfence/__init__.py
ADDED
lanfence/alerts.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Stable State Consulting Ltd
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Alert dispatch: syslog, email, webhook.
|
|
5
|
+
|
|
6
|
+
LAN Fence never calls out to any third-party service on its own - the
|
|
7
|
+
operator opts into each channel explicitly in config, and every channel here
|
|
8
|
+
is a destination *they* configured (their own syslog daemon, mail relay, or
|
|
9
|
+
webhook endpoint).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import smtplib
|
|
16
|
+
import syslog
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from email.message import EmailMessage
|
|
20
|
+
|
|
21
|
+
from lanfence.config import AlertConfig
|
|
22
|
+
from lanfence.logging_config import get_logger
|
|
23
|
+
from lanfence.models import Finding
|
|
24
|
+
|
|
25
|
+
log = get_logger("alerts")
|
|
26
|
+
|
|
27
|
+
_SEVERITY_RANK = {"info": 0, "medium": 1, "high": 2}
|
|
28
|
+
|
|
29
|
+
_SYSLOG_FACILITIES = {
|
|
30
|
+
"user": syslog.LOG_USER,
|
|
31
|
+
"daemon": syslog.LOG_DAEMON,
|
|
32
|
+
"local0": syslog.LOG_LOCAL0,
|
|
33
|
+
"local1": syslog.LOG_LOCAL1,
|
|
34
|
+
"local2": syslog.LOG_LOCAL2,
|
|
35
|
+
"local3": syslog.LOG_LOCAL3,
|
|
36
|
+
"local4": syslog.LOG_LOCAL4,
|
|
37
|
+
"local5": syslog.LOG_LOCAL5,
|
|
38
|
+
"local6": syslog.LOG_LOCAL6,
|
|
39
|
+
"local7": syslog.LOG_LOCAL7,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_SYSLOG_SEVERITY = {
|
|
43
|
+
"high": syslog.LOG_ALERT,
|
|
44
|
+
"medium": syslog.LOG_WARNING,
|
|
45
|
+
"info": syslog.LOG_INFO,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def findings_to_alert(findings: list[Finding], cfg: AlertConfig) -> list[Finding]:
|
|
50
|
+
"""Findings at or above ``cfg.min_severity``, worth dispatching."""
|
|
51
|
+
|
|
52
|
+
threshold = _SEVERITY_RANK[cfg.min_severity]
|
|
53
|
+
return [f for f in findings if _SEVERITY_RANK[f.severity] >= threshold]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def send_syslog(findings: list[Finding], cfg: AlertConfig) -> None:
|
|
57
|
+
if not cfg.syslog.enabled or not findings:
|
|
58
|
+
return
|
|
59
|
+
facility = _SYSLOG_FACILITIES.get(cfg.syslog.facility, syslog.LOG_USER)
|
|
60
|
+
syslog.openlog(ident="lanfence", facility=facility)
|
|
61
|
+
try:
|
|
62
|
+
for finding in findings:
|
|
63
|
+
priority = _SYSLOG_SEVERITY.get(finding.severity, syslog.LOG_INFO)
|
|
64
|
+
syslog.syslog(priority, f"[{finding.severity.upper()}] {finding.title} (mac={finding.mac})")
|
|
65
|
+
finally:
|
|
66
|
+
syslog.closelog()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def send_email(findings: list[Finding], cfg: AlertConfig) -> None:
|
|
70
|
+
if not cfg.email.enabled or not findings:
|
|
71
|
+
return
|
|
72
|
+
if not cfg.email.to_addrs or not cfg.email.from_addr:
|
|
73
|
+
log.warning("email alerts enabled but from_addr/to_addrs not configured; skipping")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
lines = [f"LAN Fence: {len(findings)} finding(s)\n"]
|
|
77
|
+
for finding in findings:
|
|
78
|
+
lines.append(f"[{finding.severity.upper()}] {finding.title}")
|
|
79
|
+
lines.append(f" MAC: {finding.mac}")
|
|
80
|
+
if finding.rationale:
|
|
81
|
+
lines.append(f" {finding.rationale}")
|
|
82
|
+
if finding.recommendation:
|
|
83
|
+
lines.append(f" Recommendation: {finding.recommendation}")
|
|
84
|
+
lines.append("")
|
|
85
|
+
|
|
86
|
+
msg = EmailMessage()
|
|
87
|
+
msg["Subject"] = f"LAN Fence: {len(findings)} finding(s) on your network"
|
|
88
|
+
msg["From"] = cfg.email.from_addr
|
|
89
|
+
msg["To"] = ", ".join(cfg.email.to_addrs)
|
|
90
|
+
msg.set_content("\n".join(lines))
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
with smtplib.SMTP(cfg.email.smtp_host, cfg.email.smtp_port, timeout=10) as smtp:
|
|
94
|
+
if cfg.email.use_tls:
|
|
95
|
+
smtp.starttls()
|
|
96
|
+
if cfg.email.username and cfg.email.password:
|
|
97
|
+
smtp.login(cfg.email.username, cfg.email.password)
|
|
98
|
+
smtp.send_message(msg)
|
|
99
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
100
|
+
log.error("failed to send email alert: %s", exc)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def send_webhook(findings: list[Finding], cfg: AlertConfig) -> None:
|
|
104
|
+
if not cfg.webhook.enabled or not findings:
|
|
105
|
+
return
|
|
106
|
+
if not cfg.webhook.url:
|
|
107
|
+
log.warning("webhook alerts enabled but no url configured; skipping")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
payload = json.dumps({"findings": [f.model_dump(mode="json") for f in findings]}).encode("utf-8")
|
|
111
|
+
request = urllib.request.Request(
|
|
112
|
+
cfg.webhook.url,
|
|
113
|
+
data=payload,
|
|
114
|
+
headers={"Content-Type": "application/json", "User-Agent": "lanfence"},
|
|
115
|
+
method="POST",
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
with urllib.request.urlopen(request, timeout=cfg.webhook.timeout_seconds) as resp: # noqa: S310
|
|
119
|
+
resp.read()
|
|
120
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
121
|
+
log.error("failed to send webhook alert: %s", exc)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dispatch(findings: list[Finding], cfg: AlertConfig) -> list[Finding]:
|
|
125
|
+
"""Send every finding at or above ``cfg.min_severity`` to every enabled
|
|
126
|
+
channel. Returns the findings that were dispatched."""
|
|
127
|
+
|
|
128
|
+
to_send = findings_to_alert(findings, cfg)
|
|
129
|
+
if not to_send:
|
|
130
|
+
return []
|
|
131
|
+
send_syslog(to_send, cfg)
|
|
132
|
+
send_email(to_send, cfg)
|
|
133
|
+
send_webhook(to_send, cfg)
|
|
134
|
+
return to_send
|
lanfence/allowlist.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Stable State Consulting Ltd
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The allowlist of devices you trust (``lanfence allow``).
|
|
5
|
+
|
|
6
|
+
Findings about an allowlisted device are downgraded to ``info`` so your own
|
|
7
|
+
router, phones and laptops stop showing up as "unknown device" every time they
|
|
8
|
+
reconnect. This is just data - allowlisting a MAC does not verify it, and MAC
|
|
9
|
+
addresses are trivially spoofed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from lanfence.fsutil import atomic_write
|
|
20
|
+
from lanfence.logging_config import get_logger
|
|
21
|
+
from lanfence.netutil import normalize_mac
|
|
22
|
+
|
|
23
|
+
log = get_logger("allowlist")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AllowEntry:
|
|
28
|
+
mac: str
|
|
29
|
+
name: str
|
|
30
|
+
notes: str = ""
|
|
31
|
+
|
|
32
|
+
def as_dict(self) -> dict:
|
|
33
|
+
row = {"mac": self.mac, "name": self.name}
|
|
34
|
+
if self.notes:
|
|
35
|
+
row["notes"] = self.notes
|
|
36
|
+
return row
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Allowlist:
|
|
40
|
+
def __init__(self, entries: list[AllowEntry], path: Path | None = None) -> None:
|
|
41
|
+
self.entries = entries
|
|
42
|
+
self.path = path
|
|
43
|
+
|
|
44
|
+
def __len__(self) -> int:
|
|
45
|
+
return len(self.entries)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def load(cls, path: Path | str | None) -> "Allowlist":
|
|
49
|
+
if path is None:
|
|
50
|
+
return cls([], None)
|
|
51
|
+
path = Path(path)
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
return cls([], path)
|
|
54
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
55
|
+
entries: list[AllowEntry] = []
|
|
56
|
+
for row in data.get("allow", []) or []:
|
|
57
|
+
try:
|
|
58
|
+
entries.append(
|
|
59
|
+
AllowEntry(
|
|
60
|
+
mac=normalize_mac(str(row["mac"])),
|
|
61
|
+
name=str(row.get("name") or "unnamed"),
|
|
62
|
+
notes=str(row.get("notes") or ""),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
except (KeyError, TypeError, ValueError):
|
|
66
|
+
log.warning("skipping malformed allowlist entry: %r", row)
|
|
67
|
+
return cls(entries, path)
|
|
68
|
+
|
|
69
|
+
def save(self) -> None:
|
|
70
|
+
if self.path is None:
|
|
71
|
+
raise ValueError("allowlist has no path to save to")
|
|
72
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
body = yaml.safe_dump({"allow": [e.as_dict() for e in self.entries]}, sort_keys=False)
|
|
74
|
+
atomic_write(
|
|
75
|
+
self.path,
|
|
76
|
+
"# LAN Fence allowlist - devices you trust; their findings are\n"
|
|
77
|
+
"# downgraded to info. A MAC address is trivially spoofed, so this is\n"
|
|
78
|
+
"# a mute button, not proof of identity.\n" + body,
|
|
79
|
+
mode=0o644,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def match(self, mac: str) -> AllowEntry | None:
|
|
83
|
+
norm = normalize_mac(mac)
|
|
84
|
+
for entry in self.entries:
|
|
85
|
+
if entry.mac == norm:
|
|
86
|
+
return entry
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def add(self, mac: str, name: str, notes: str = "") -> AllowEntry:
|
|
90
|
+
norm = normalize_mac(mac)
|
|
91
|
+
entry = AllowEntry(norm, name, notes)
|
|
92
|
+
self.entries = [e for e in self.entries if e.mac != norm]
|
|
93
|
+
self.entries.append(entry)
|
|
94
|
+
return entry
|
|
95
|
+
|
|
96
|
+
def remove(self, mac: str) -> AllowEntry | None:
|
|
97
|
+
norm = normalize_mac(mac)
|
|
98
|
+
existing = self.match(norm)
|
|
99
|
+
if existing is not None:
|
|
100
|
+
self.entries = [e for e in self.entries if e.mac != norm]
|
|
101
|
+
return existing
|
lanfence/cli.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Stable State Consulting Ltd
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""LAN Fence command-line interface (Typer)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import queue
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from lanfence import __version__, alerts, scanner
|
|
21
|
+
from lanfence.allowlist import Allowlist
|
|
22
|
+
from lanfence.config import Config
|
|
23
|
+
from lanfence.db import DeviceStore
|
|
24
|
+
from lanfence.engine import build_findings, process_sighting, run_active_sweep
|
|
25
|
+
from lanfence.fingerprint import SignatureSet, fingerprint_device
|
|
26
|
+
from lanfence.logging_config import setup_logging
|
|
27
|
+
from lanfence.models import Finding
|
|
28
|
+
from lanfence.report import (
|
|
29
|
+
exit_code_for,
|
|
30
|
+
exit_code_for_findings,
|
|
31
|
+
render_events,
|
|
32
|
+
render_findings,
|
|
33
|
+
render_scan_result,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
add_completion=False,
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
help=(
|
|
40
|
+
"LAN Fence - defensive LAN device monitor.\n\n"
|
|
41
|
+
"Scans for connected devices via ARP, tracks them against an allowlist "
|
|
42
|
+
"of devices you trust, and alerts when something unknown joins your "
|
|
43
|
+
"network. Observation only; LAN Fence never modifies the network."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _version_callback(value: bool) -> None:
|
|
49
|
+
if value:
|
|
50
|
+
typer.echo(f"lanfence {__version__}")
|
|
51
|
+
raise typer.Exit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback()
|
|
55
|
+
def _root(
|
|
56
|
+
version: bool = typer.Option(
|
|
57
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_config(config_path: Optional[Path]) -> Config:
|
|
64
|
+
try:
|
|
65
|
+
return Config.load(config_path)
|
|
66
|
+
except Exception as exc: # noqa: BLE001
|
|
67
|
+
typer.secho(f"error: could not load config: {exc}", fg="red", err=True)
|
|
68
|
+
raise typer.Exit(code=2) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_root() -> bool:
|
|
72
|
+
return hasattr(os, "geteuid") and os.geteuid() == 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_since(value: str) -> datetime:
|
|
79
|
+
"""Parse a duration like ``24h``, ``30m``, ``7d`` into a UTC cutoff datetime."""
|
|
80
|
+
|
|
81
|
+
value = value.strip().lower()
|
|
82
|
+
if value and value[-1] in _UNIT_SECONDS and value[:-1].replace(".", "", 1).isdigit():
|
|
83
|
+
amount = float(value[:-1])
|
|
84
|
+
seconds = amount * _UNIT_SECONDS[value[-1]]
|
|
85
|
+
return datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
|
86
|
+
typer.secho(
|
|
87
|
+
f"error: could not parse --since {value!r} (expected e.g. 24h, 30m, 7d)",
|
|
88
|
+
fg="red", err=True,
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(code=2)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def scan(
|
|
95
|
+
interface: Optional[str] = typer.Option(None, "--interface", "-i", help="Network interface to scan."),
|
|
96
|
+
subnet: Optional[str] = typer.Option(None, "--subnet", "-s", help="CIDR subnet to scan (default: auto-detect)."),
|
|
97
|
+
output_format: str = typer.Option("table", "--format", "-f", help="table | json"),
|
|
98
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
|
|
99
|
+
alert: bool = typer.Option(False, "--alert", help="Dispatch alerts for findings via configured channels."),
|
|
100
|
+
fail_on_findings: bool = typer.Option(
|
|
101
|
+
False, "--fail-on-findings", help="Exit non-zero when medium+ findings are present."
|
|
102
|
+
),
|
|
103
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""One-time active ARP scan; shows connected devices and any findings."""
|
|
106
|
+
|
|
107
|
+
setup_logging(verbose)
|
|
108
|
+
cfg = _load_config(config)
|
|
109
|
+
|
|
110
|
+
if not _is_root():
|
|
111
|
+
typer.secho(
|
|
112
|
+
"warning: not running as root - active ARP scanning needs raw-socket "
|
|
113
|
+
"access. Re-run with sudo if this returns no devices.",
|
|
114
|
+
fg="yellow", err=True,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
signatures = SignatureSet.load(cfg.rogue_signatures_file)
|
|
118
|
+
allowlist = Allowlist.load(cfg.resolved_allowlist_file())
|
|
119
|
+
|
|
120
|
+
with DeviceStore(cfg.resolved_db_path()) as store:
|
|
121
|
+
result = run_active_sweep(cfg, store, allowlist, signatures, interface=interface, subnet=subnet)
|
|
122
|
+
|
|
123
|
+
if alert:
|
|
124
|
+
alerts.dispatch(result.findings, cfg.alerts)
|
|
125
|
+
|
|
126
|
+
if output_format == "json":
|
|
127
|
+
typer.echo(result.to_json())
|
|
128
|
+
else:
|
|
129
|
+
render_scan_result(result)
|
|
130
|
+
|
|
131
|
+
if fail_on_findings:
|
|
132
|
+
raise typer.Exit(code=exit_code_for(result))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _emit_findings(findings: list[Finding], *, alert: bool, cfg: Config) -> None:
|
|
136
|
+
colour = {"high": "red", "medium": "yellow", "info": "cyan"}
|
|
137
|
+
for finding in findings:
|
|
138
|
+
typer.secho(
|
|
139
|
+
f"[{finding.severity.upper()}] {finding.title} (mac={finding.mac})",
|
|
140
|
+
fg=colour.get(finding.severity, "white"), bold=(finding.severity == "high"),
|
|
141
|
+
)
|
|
142
|
+
if finding.rationale:
|
|
143
|
+
typer.echo(f" {finding.rationale}")
|
|
144
|
+
if finding.recommendation:
|
|
145
|
+
typer.secho(f" Recommendation: {finding.recommendation}", fg="cyan")
|
|
146
|
+
if alert and findings:
|
|
147
|
+
alerts.dispatch(findings, cfg.alerts)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def monitor(
|
|
152
|
+
interface: Optional[str] = typer.Option(None, "--interface", "-i", help="Network interface to monitor."),
|
|
153
|
+
subnet: Optional[str] = typer.Option(None, "--subnet", "-s", help="CIDR subnet to actively sweep."),
|
|
154
|
+
interval: Optional[float] = typer.Option(None, "--interval", help="Active-sweep interval override (seconds)."),
|
|
155
|
+
passive: Optional[bool] = typer.Option(
|
|
156
|
+
None, "--passive/--no-passive", help="Also passively sniff ARP traffic between sweeps."
|
|
157
|
+
),
|
|
158
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
|
|
159
|
+
alert: bool = typer.Option(True, "--alert/--no-alert", help="Dispatch alerts for findings as they occur."),
|
|
160
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Continuously watch for new/changed devices until interrupted (Ctrl+C)."""
|
|
163
|
+
|
|
164
|
+
setup_logging(verbose)
|
|
165
|
+
cfg = _load_config(config)
|
|
166
|
+
if interval is not None:
|
|
167
|
+
cfg.scan.scan_interval_seconds = interval
|
|
168
|
+
if passive is not None:
|
|
169
|
+
cfg.scan.passive = passive
|
|
170
|
+
|
|
171
|
+
if not _is_root():
|
|
172
|
+
typer.secho(
|
|
173
|
+
"warning: not running as root - ARP scanning/sniffing needs raw-socket "
|
|
174
|
+
"access. Re-run with sudo for full monitoring.",
|
|
175
|
+
fg="yellow", err=True,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
signatures = SignatureSet.load(cfg.rogue_signatures_file)
|
|
179
|
+
allowlist = Allowlist.load(cfg.resolved_allowlist_file())
|
|
180
|
+
store = DeviceStore(cfg.resolved_db_path())
|
|
181
|
+
|
|
182
|
+
iface = interface or cfg.scan.interface or scanner.default_interface()
|
|
183
|
+
net = subnet or cfg.scan.subnet
|
|
184
|
+
|
|
185
|
+
typer.secho(f"LAN Fence {__version__} - monitoring (Ctrl+C to stop)", fg="green", bold=True)
|
|
186
|
+
typer.echo(
|
|
187
|
+
f"interface: {iface or '(auto)'} scan interval: {cfg.scan.scan_interval_seconds:.0f}s "
|
|
188
|
+
f"passive: {cfg.scan.passive}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
stop_event = threading.Event()
|
|
192
|
+
passive_queue: "queue.Queue[scanner.ArpSighting]" = queue.Queue()
|
|
193
|
+
|
|
194
|
+
def _run_passive() -> None:
|
|
195
|
+
try:
|
|
196
|
+
scanner.passive_sniff(on_sighting=passive_queue.put, interface=iface, stop_event=stop_event)
|
|
197
|
+
except scanner.ScannerUnavailable as exc:
|
|
198
|
+
typer.secho(f"passive monitoring unavailable: {exc}", fg="yellow", err=True)
|
|
199
|
+
|
|
200
|
+
passive_thread: threading.Thread | None = None
|
|
201
|
+
if cfg.scan.passive:
|
|
202
|
+
passive_thread = threading.Thread(target=_run_passive, daemon=True)
|
|
203
|
+
passive_thread.start()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
last_sweep = 0.0
|
|
207
|
+
while True:
|
|
208
|
+
now = time.monotonic()
|
|
209
|
+
if now - last_sweep >= cfg.scan.scan_interval_seconds:
|
|
210
|
+
result = run_active_sweep(cfg, store, allowlist, signatures, interface=iface, subnet=net)
|
|
211
|
+
last_sweep = now
|
|
212
|
+
for err in result.errors:
|
|
213
|
+
typer.secho(f"error: {err}", fg="red", err=True)
|
|
214
|
+
_emit_findings(result.findings, alert=alert, cfg=cfg)
|
|
215
|
+
|
|
216
|
+
drained = 0
|
|
217
|
+
while drained < 200:
|
|
218
|
+
try:
|
|
219
|
+
sighting = passive_queue.get_nowait()
|
|
220
|
+
except queue.Empty:
|
|
221
|
+
break
|
|
222
|
+
drained += 1
|
|
223
|
+
_, _event_type, findings = process_sighting(
|
|
224
|
+
mac=sighting.mac, ip=sighting.ip, seen_at=sighting.seen_at,
|
|
225
|
+
store=store, allowlist=allowlist, signatures=signatures, cfg=cfg,
|
|
226
|
+
)
|
|
227
|
+
_emit_findings(findings, alert=alert, cfg=cfg)
|
|
228
|
+
|
|
229
|
+
time.sleep(1.0)
|
|
230
|
+
except KeyboardInterrupt:
|
|
231
|
+
typer.echo("\nstopping monitor...")
|
|
232
|
+
finally:
|
|
233
|
+
stop_event.set()
|
|
234
|
+
store.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command()
|
|
238
|
+
def report(
|
|
239
|
+
since: str = typer.Option("24h", "--since", help="How far back to report, e.g. 30m, 24h, 7d."),
|
|
240
|
+
output_format: str = typer.Option("table", "--format", "-f", help="table | json"),
|
|
241
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
|
|
242
|
+
fail_on_findings: bool = typer.Option(
|
|
243
|
+
False, "--fail-on-findings", help="Exit non-zero when medium+ findings are present in the window."
|
|
244
|
+
),
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Summarize device activity (connects/disconnects/reappearances) since a point in time."""
|
|
247
|
+
|
|
248
|
+
cfg = _load_config(config)
|
|
249
|
+
since_dt = _parse_since(since)
|
|
250
|
+
signatures = SignatureSet.load(cfg.rogue_signatures_file)
|
|
251
|
+
allowlist = Allowlist.load(cfg.resolved_allowlist_file())
|
|
252
|
+
|
|
253
|
+
with DeviceStore(cfg.resolved_db_path()) as store:
|
|
254
|
+
events = store.events_since(since_dt)
|
|
255
|
+
devices_by_mac = {d.mac: d for d in store.all_devices()}
|
|
256
|
+
|
|
257
|
+
findings: list[Finding] = []
|
|
258
|
+
for event in events:
|
|
259
|
+
if event.event_type == "disconnected":
|
|
260
|
+
continue
|
|
261
|
+
device = devices_by_mac.get(event.mac)
|
|
262
|
+
if device is None:
|
|
263
|
+
continue
|
|
264
|
+
allow_entry = allowlist.match(event.mac)
|
|
265
|
+
device = device.model_copy(
|
|
266
|
+
update={
|
|
267
|
+
"allowlisted": allow_entry is not None,
|
|
268
|
+
"allowlist_name": allow_entry.name if allow_entry else None,
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
_, matches = fingerprint_device(
|
|
272
|
+
event.mac, device.hostname, signatures=signatures, vendor_file=cfg.vendor_file
|
|
273
|
+
)
|
|
274
|
+
findings.extend(build_findings(device, event.event_type, matches))
|
|
275
|
+
|
|
276
|
+
if output_format == "json":
|
|
277
|
+
payload = {
|
|
278
|
+
"since": since_dt.isoformat(),
|
|
279
|
+
"events": [e.model_dump(mode="json") for e in events],
|
|
280
|
+
"findings": [f.model_dump(mode="json") for f in findings],
|
|
281
|
+
}
|
|
282
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
283
|
+
else:
|
|
284
|
+
typer.secho(f"LAN Fence report - since {since_dt.isoformat()}", fg="cyan", bold=True)
|
|
285
|
+
render_events(events)
|
|
286
|
+
typer.echo("")
|
|
287
|
+
render_findings(findings)
|
|
288
|
+
|
|
289
|
+
if fail_on_findings:
|
|
290
|
+
raise typer.Exit(code=exit_code_for_findings(findings))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.command()
|
|
294
|
+
def allow(
|
|
295
|
+
mac: Optional[str] = typer.Argument(None, help="MAC address to trust."),
|
|
296
|
+
name: Optional[str] = typer.Option(None, "--name", help="Label for this device."),
|
|
297
|
+
notes: Optional[str] = typer.Option(None, "--notes", help="Freeform notes."),
|
|
298
|
+
list_entries: bool = typer.Option(False, "--list", help="Show current allowlist entries."),
|
|
299
|
+
remove: Optional[str] = typer.Option(None, "--remove", help="MAC address to remove from the allowlist."),
|
|
300
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Manage the allowlist of devices you trust.
|
|
303
|
+
|
|
304
|
+
Findings about an allowlisted device are downgraded to *info*, so your own
|
|
305
|
+
hardware stops shouting every time it reconnects. With no MAC and no
|
|
306
|
+
options, lists the current entries.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
cfg = _load_config(config)
|
|
310
|
+
path = cfg.resolved_allowlist_file()
|
|
311
|
+
al = Allowlist.load(path)
|
|
312
|
+
al.path = path
|
|
313
|
+
|
|
314
|
+
if remove is not None:
|
|
315
|
+
entry = al.remove(remove)
|
|
316
|
+
if entry is None:
|
|
317
|
+
typer.secho(f"error: {remove} is not on the allowlist", fg="red", err=True)
|
|
318
|
+
raise typer.Exit(code=2)
|
|
319
|
+
al.save()
|
|
320
|
+
typer.secho(f"removed: {entry.name} ({entry.mac})", fg="green")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
if list_entries or mac is None:
|
|
324
|
+
if not al.entries:
|
|
325
|
+
typer.echo(f"allowlist is empty ({path})")
|
|
326
|
+
return
|
|
327
|
+
typer.secho(f"Allowlist ({path}):\n", fg="cyan", bold=True)
|
|
328
|
+
for i, e in enumerate(al.entries, start=1):
|
|
329
|
+
tail = f" {e.notes}" if e.notes else ""
|
|
330
|
+
typer.echo(f" {i:>3}. {e.name:<28} {e.mac}{tail}")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
entry = al.add(mac, name or mac, notes or "")
|
|
334
|
+
al.save()
|
|
335
|
+
typer.secho(f"added: {entry.name} ({entry.mac})", fg="green")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@app.command()
|
|
339
|
+
def check(
|
|
340
|
+
config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Check that this host can run LAN Fence (permissions, scapy, interface)."""
|
|
343
|
+
|
|
344
|
+
cfg = _load_config(config)
|
|
345
|
+
|
|
346
|
+
typer.secho("Host", fg="cyan", bold=True)
|
|
347
|
+
typer.echo(f" root: {_is_root()}")
|
|
348
|
+
typer.echo(f" platform: {sys.platform}")
|
|
349
|
+
|
|
350
|
+
typer.secho("\nScanning", fg="cyan", bold=True)
|
|
351
|
+
try:
|
|
352
|
+
import scapy # noqa: F401
|
|
353
|
+
|
|
354
|
+
typer.secho(" ok scapy is installed", fg="green")
|
|
355
|
+
except ImportError:
|
|
356
|
+
typer.secho(
|
|
357
|
+
" MISSING scapy is not installed (pip install 'lanfence[scan]')", fg="yellow"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
iface = cfg.scan.interface or scanner.default_interface()
|
|
361
|
+
typer.echo(f" interface: {iface or '(could not auto-detect)'}")
|
|
362
|
+
net = cfg.scan.subnet or scanner.local_subnet(iface)
|
|
363
|
+
typer.echo(f" subnet: {net or '(could not auto-detect)'}")
|
|
364
|
+
|
|
365
|
+
typer.secho("\nStorage", fg="cyan", bold=True)
|
|
366
|
+
db_path = cfg.resolved_db_path()
|
|
367
|
+
try:
|
|
368
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
DeviceStore(db_path).close()
|
|
370
|
+
typer.secho(f" ok database writable: {db_path}", fg="green")
|
|
371
|
+
db_ok = True
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
typer.secho(f" FAIL database not writable: {db_path} ({exc})", fg="red")
|
|
374
|
+
db_ok = False
|
|
375
|
+
|
|
376
|
+
allowlist_file = cfg.resolved_allowlist_file()
|
|
377
|
+
typer.echo(f" allowlist: {allowlist_file} ({'exists' if allowlist_file.is_file() else 'not created yet'})")
|
|
378
|
+
|
|
379
|
+
if not _is_root():
|
|
380
|
+
typer.secho(
|
|
381
|
+
"\nnot running as root: active/passive ARP scanning will likely fail. "
|
|
382
|
+
"Re-run with sudo for a full check.",
|
|
383
|
+
fg="yellow",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
raise typer.Exit(code=0 if db_ok else 1)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def main() -> None: # pragma: no cover - entry point shim
|
|
390
|
+
app()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__": # pragma: no cover
|
|
394
|
+
sys.exit(app())
|