serverscout 0.1.0__tar.gz
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.
- serverscout-0.1.0/PKG-INFO +82 -0
- serverscout-0.1.0/README.md +71 -0
- serverscout-0.1.0/pyproject.toml +22 -0
- serverscout-0.1.0/serverscout/__init__.py +80 -0
- serverscout-0.1.0/serverscout/checks/__init__.py +0 -0
- serverscout-0.1.0/serverscout/checks/firewall.py +30 -0
- serverscout-0.1.0/serverscout/checks/persistence.py +66 -0
- serverscout-0.1.0/serverscout/checks/ports.py +58 -0
- serverscout-0.1.0/serverscout/checks/ssh_config.py +34 -0
- serverscout-0.1.0/serverscout/cli.py +62 -0
- serverscout-0.1.0/serverscout/connector.py +37 -0
- serverscout-0.1.0/serverscout/reporter.py +60 -0
- serverscout-0.1.0/serverscout.egg-info/PKG-INFO +82 -0
- serverscout-0.1.0/serverscout.egg-info/SOURCES.txt +17 -0
- serverscout-0.1.0/serverscout.egg-info/dependency_links.txt +1 -0
- serverscout-0.1.0/serverscout.egg-info/entry_points.txt +2 -0
- serverscout-0.1.0/serverscout.egg-info/requires.txt +3 -0
- serverscout-0.1.0/serverscout.egg-info/top_level.txt +2 -0
- serverscout-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serverscout
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight SSH-based security scanner for Linux servers
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: paramiko
|
|
9
|
+
Requires-Dist: click
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
|
|
12
|
+
# serverscout
|
|
13
|
+
|
|
14
|
+
Lightweight SSH-based security scanner for Linux servers.
|
|
15
|
+
|
|
16
|
+
Checks open ports, firewall rules, SSH config, and malware persistence — no agent required.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install serverscout
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## CLI usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
serverscout --host 1.2.3.4 --key ~/.ssh/id_rsa
|
|
28
|
+
serverscout --host 1.2.3.4 --password mypass
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Python usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from serverscout import scan
|
|
35
|
+
|
|
36
|
+
results = scan([
|
|
37
|
+
{"host": "1.2.3.4", "key": "~/.ssh/id_rsa"},
|
|
38
|
+
{"host": "5.6.7.8", "password": "mypass"},
|
|
39
|
+
])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Disable Rich output and process results yourself:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
results = scan(servers, report=False)
|
|
46
|
+
|
|
47
|
+
for host, data in results.items():
|
|
48
|
+
if data["status"] == "error":
|
|
49
|
+
print(f"{host}: connection failed — {data['error']}")
|
|
50
|
+
continue
|
|
51
|
+
criticals = [
|
|
52
|
+
msg for section in data["results"].values()
|
|
53
|
+
for severity, msg in section
|
|
54
|
+
if severity == "critical"
|
|
55
|
+
]
|
|
56
|
+
if criticals:
|
|
57
|
+
print(f"{host}: {len(criticals)} critical issues")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Parallel scanning
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from serverscout import scan
|
|
64
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
65
|
+
|
|
66
|
+
servers = [{"host": f"10.0.0.{i}", "key": "~/.ssh/id_rsa"} for i in range(1, 20)]
|
|
67
|
+
|
|
68
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
69
|
+
futures = [executor.submit(scan, [s], report=False) for s in servers]
|
|
70
|
+
results = {}
|
|
71
|
+
for f in futures:
|
|
72
|
+
results.update(f.result())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What it checks
|
|
76
|
+
|
|
77
|
+
| Section | Checks |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Ports | Dangerous ports exposed to the internet (Redis, Postgres, MongoDB, etc.) |
|
|
80
|
+
| Firewall | iptables/nftables rules, fail2ban |
|
|
81
|
+
| SSH Config | Root login, password auth, empty passwords, X11 forwarding |
|
|
82
|
+
| Persistence | Executables in /tmp, postgres crontab, known malware processes, suspicious outgoing connections |
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# serverscout
|
|
2
|
+
|
|
3
|
+
Lightweight SSH-based security scanner for Linux servers.
|
|
4
|
+
|
|
5
|
+
Checks open ports, firewall rules, SSH config, and malware persistence — no agent required.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install serverscout
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CLI usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
serverscout --host 1.2.3.4 --key ~/.ssh/id_rsa
|
|
17
|
+
serverscout --host 1.2.3.4 --password mypass
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Python usage
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from serverscout import scan
|
|
24
|
+
|
|
25
|
+
results = scan([
|
|
26
|
+
{"host": "1.2.3.4", "key": "~/.ssh/id_rsa"},
|
|
27
|
+
{"host": "5.6.7.8", "password": "mypass"},
|
|
28
|
+
])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Disable Rich output and process results yourself:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
results = scan(servers, report=False)
|
|
35
|
+
|
|
36
|
+
for host, data in results.items():
|
|
37
|
+
if data["status"] == "error":
|
|
38
|
+
print(f"{host}: connection failed — {data['error']}")
|
|
39
|
+
continue
|
|
40
|
+
criticals = [
|
|
41
|
+
msg for section in data["results"].values()
|
|
42
|
+
for severity, msg in section
|
|
43
|
+
if severity == "critical"
|
|
44
|
+
]
|
|
45
|
+
if criticals:
|
|
46
|
+
print(f"{host}: {len(criticals)} critical issues")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Parallel scanning
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from serverscout import scan
|
|
53
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
54
|
+
|
|
55
|
+
servers = [{"host": f"10.0.0.{i}", "key": "~/.ssh/id_rsa"} for i in range(1, 20)]
|
|
56
|
+
|
|
57
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
58
|
+
futures = [executor.submit(scan, [s], report=False) for s in servers]
|
|
59
|
+
results = {}
|
|
60
|
+
for f in futures:
|
|
61
|
+
results.update(f.result())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What it checks
|
|
65
|
+
|
|
66
|
+
| Section | Checks |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Ports | Dangerous ports exposed to the internet (Redis, Postgres, MongoDB, etc.) |
|
|
69
|
+
| Firewall | iptables/nftables rules, fail2ban |
|
|
70
|
+
| SSH Config | Root login, password auth, empty passwords, X11 forwarding |
|
|
71
|
+
| Persistence | Executables in /tmp, postgres crontab, known malware processes, suspicious outgoing connections |
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=70", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "serverscout"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight SSH-based security scanner for Linux servers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"paramiko",
|
|
14
|
+
"click",
|
|
15
|
+
"rich",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
serverscout = "serverscout.cli:scan"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["."]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from .connector import Connector
|
|
2
|
+
from .checks.ports import Ports
|
|
3
|
+
from .checks.firewall import Firewall
|
|
4
|
+
from .checks.ssh_config import SSHConfig
|
|
5
|
+
from .checks.persistence import Persistence
|
|
6
|
+
from .reporter import Reporter
|
|
7
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scan(servers: list[dict], report: bool = True) -> dict:
|
|
11
|
+
"""
|
|
12
|
+
Scan one or more servers for security vulnerabilities.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
servers: list of dicts with keys:
|
|
16
|
+
host (required), user (default: root),
|
|
17
|
+
key, password, port (default: 22)
|
|
18
|
+
report: print Rich report to stdout (default: True)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
dict keyed by host with scan results per section
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
from serverscout import scan
|
|
25
|
+
|
|
26
|
+
results = scan([
|
|
27
|
+
{"host": "1.2.3.4", "key": "~/.ssh/id_rsa"},
|
|
28
|
+
{"host": "5.6.7.8", "password": "mypass"},
|
|
29
|
+
])
|
|
30
|
+
"""
|
|
31
|
+
all_results = {}
|
|
32
|
+
|
|
33
|
+
with Progress(
|
|
34
|
+
SpinnerColumn(),
|
|
35
|
+
TextColumn("[progress.description]{task.description}"),
|
|
36
|
+
transient=True,
|
|
37
|
+
) as progress:
|
|
38
|
+
for server in servers:
|
|
39
|
+
host = server["host"]
|
|
40
|
+
task = progress.add_task(f"Connecting to {host}...", total=4)
|
|
41
|
+
|
|
42
|
+
conn_args = {
|
|
43
|
+
"host": host,
|
|
44
|
+
"user": server.get("user", "root"),
|
|
45
|
+
"key_path": server.get("key"),
|
|
46
|
+
"password": server.get("password"),
|
|
47
|
+
"port": server.get("port", 22),
|
|
48
|
+
}
|
|
49
|
+
try:
|
|
50
|
+
with Connector(**conn_args) as conn:
|
|
51
|
+
progress.update(task, description=f"[{host}] Checking ports...")
|
|
52
|
+
ports = Ports(conn).check_vulnerable_ports()
|
|
53
|
+
progress.advance(task)
|
|
54
|
+
|
|
55
|
+
progress.update(task, description=f"[{host}] Checking firewall...")
|
|
56
|
+
firewall = Firewall(conn).check()
|
|
57
|
+
progress.advance(task)
|
|
58
|
+
|
|
59
|
+
progress.update(task, description=f"[{host}] Checking SSH config...")
|
|
60
|
+
ssh = SSHConfig(conn).check()
|
|
61
|
+
progress.advance(task)
|
|
62
|
+
|
|
63
|
+
progress.update(task, description=f"[{host}] Checking persistence...")
|
|
64
|
+
persistence = Persistence(conn).check()
|
|
65
|
+
progress.advance(task)
|
|
66
|
+
|
|
67
|
+
results = {
|
|
68
|
+
"ports": ports,
|
|
69
|
+
"firewall": firewall,
|
|
70
|
+
"ssh_config": ssh,
|
|
71
|
+
"persistence": persistence,
|
|
72
|
+
}
|
|
73
|
+
if report:
|
|
74
|
+
Reporter(host, results).render()
|
|
75
|
+
all_results[host] = {"status": "ok", "results": results}
|
|
76
|
+
|
|
77
|
+
except Exception as e:
|
|
78
|
+
all_results[host] = {"status": "error", "error": str(e)}
|
|
79
|
+
|
|
80
|
+
return all_results
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class Firewall:
|
|
2
|
+
def __init__(self, conn):
|
|
3
|
+
self.conn = conn
|
|
4
|
+
|
|
5
|
+
def check(self):
|
|
6
|
+
findings = []
|
|
7
|
+
|
|
8
|
+
iptables = self.conn.run("iptables -L INPUT -n --line-numbers 2>/dev/null")
|
|
9
|
+
nft = self.conn.run("nft list ruleset 2>/dev/null")
|
|
10
|
+
|
|
11
|
+
if not iptables and not nft:
|
|
12
|
+
findings.append(("critical", "No firewall detected (neither iptables nor nftables)"))
|
|
13
|
+
return findings
|
|
14
|
+
|
|
15
|
+
if iptables:
|
|
16
|
+
if "ACCEPT" in iptables and "DROP" not in iptables and "REJECT" not in iptables:
|
|
17
|
+
findings.append(("warn", "iptables accepts all traffic, no DROP/REJECT rules found"))
|
|
18
|
+
else:
|
|
19
|
+
findings.append(("ok", "iptables is active with restrictive rules"))
|
|
20
|
+
|
|
21
|
+
if nft and not iptables:
|
|
22
|
+
findings.append(("ok", "nftables is active"))
|
|
23
|
+
|
|
24
|
+
fail2ban = self.conn.run("systemctl is-active fail2ban 2>/dev/null")
|
|
25
|
+
if fail2ban != "active":
|
|
26
|
+
findings.append(("warn", "fail2ban is not running"))
|
|
27
|
+
else:
|
|
28
|
+
findings.append(("ok", "fail2ban is active"))
|
|
29
|
+
|
|
30
|
+
return findings
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class Persistence:
|
|
2
|
+
def __init__(self, conn):
|
|
3
|
+
self.conn = conn
|
|
4
|
+
|
|
5
|
+
def check(self):
|
|
6
|
+
findings = []
|
|
7
|
+
|
|
8
|
+
# check /tmp and /var/tmp for executable files
|
|
9
|
+
tmp_exec = self.conn.run("find /tmp /var/tmp -type f -executable 2>/dev/null")
|
|
10
|
+
if tmp_exec:
|
|
11
|
+
for f in tmp_exec.splitlines():
|
|
12
|
+
findings.append(("critical", f"Executable file in /tmp: {f}"))
|
|
13
|
+
else:
|
|
14
|
+
findings.append(("ok", "No executable files found in /tmp"))
|
|
15
|
+
|
|
16
|
+
# check crontab for all users with login shells
|
|
17
|
+
users = self.conn.run("getent passwd | awk -F: '$7 !~ /nologin|false/ {print $1}'")
|
|
18
|
+
for user in users.splitlines():
|
|
19
|
+
cron = self.conn.run(f"crontab -u {user} -l 2>/dev/null")
|
|
20
|
+
if cron and "no crontab" not in cron:
|
|
21
|
+
for line in cron.splitlines():
|
|
22
|
+
line = line.strip()
|
|
23
|
+
if not line or line.startswith("#"):
|
|
24
|
+
continue
|
|
25
|
+
findings.append(("warn", f"Crontab entry for {user}: {line}"))
|
|
26
|
+
|
|
27
|
+
# check for suspicious systemd user services under postgres
|
|
28
|
+
pg_systemd = self.conn.run("ls /var/lib/postgresql/.config/systemd/user/ 2>/dev/null")
|
|
29
|
+
if pg_systemd:
|
|
30
|
+
findings.append(("critical", f"Systemd user services found under postgres: {pg_systemd}"))
|
|
31
|
+
else:
|
|
32
|
+
findings.append(("ok", "No systemd user services found under postgres"))
|
|
33
|
+
|
|
34
|
+
# check .bashrc files for suspicious entries
|
|
35
|
+
for user_home in ["/root", "/var/lib/postgresql"]:
|
|
36
|
+
bashrc = self.conn.run(f"cat {user_home}/.bashrc 2>/dev/null")
|
|
37
|
+
suspicious = [
|
|
38
|
+
line for line in bashrc.splitlines()
|
|
39
|
+
if any(x in line for x in ["curl", "wget", "bash -i", "/tmp", "nc ", "ncat"])
|
|
40
|
+
]
|
|
41
|
+
if suspicious:
|
|
42
|
+
findings.append(("critical", f"Suspicious entry in {user_home}/.bashrc: {suspicious[0]}"))
|
|
43
|
+
|
|
44
|
+
# check for known malware process names
|
|
45
|
+
malware_names = ["reflect_client", "reflect_restart", "kinsing", "kdevtmpfsi", "xmrig"]
|
|
46
|
+
procs = self.conn.run("ps aux")
|
|
47
|
+
for name in malware_names:
|
|
48
|
+
if name in procs:
|
|
49
|
+
findings.append(("critical", f"Suspicious process detected: {name}"))
|
|
50
|
+
|
|
51
|
+
# check for outgoing connections on known malware ports
|
|
52
|
+
suspicious_conns = self.conn.run(
|
|
53
|
+
"ss -tnp state established 2>/dev/null | awk 'NR>1 {print $4, $5}'"
|
|
54
|
+
)
|
|
55
|
+
for line in suspicious_conns.splitlines():
|
|
56
|
+
parts = line.split()
|
|
57
|
+
if len(parts) >= 2:
|
|
58
|
+
dst = parts[1]
|
|
59
|
+
try:
|
|
60
|
+
dst_port = int(dst.rsplit(":", 1)[-1])
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue
|
|
63
|
+
if dst_port in (9999, 4444, 1337, 31337):
|
|
64
|
+
findings.append(("critical", f"Suspicious outgoing connection: {line}"))
|
|
65
|
+
|
|
66
|
+
return findings
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
SHOULD_NOT_BE_OPEN = {
|
|
2
|
+
5432: "PostgreSQL",
|
|
3
|
+
3306: "MySQL",
|
|
4
|
+
6379: "Redis",
|
|
5
|
+
9000: "ClickHouse native",
|
|
6
|
+
9009: "ClickHouse HTTP",
|
|
7
|
+
27017: "MongoDB",
|
|
8
|
+
5984: "CouchDB",
|
|
9
|
+
2379: "etcd",
|
|
10
|
+
2380: "etcd peer",
|
|
11
|
+
8500: "Consul",
|
|
12
|
+
9200: "Elasticsearch",
|
|
13
|
+
9300: "Elasticsearch cluster",
|
|
14
|
+
5601: "Kibana",
|
|
15
|
+
8888: "Dev server",
|
|
16
|
+
9090: "Prometheus",
|
|
17
|
+
3000: "Grafana",
|
|
18
|
+
9100: "Node Exporter",
|
|
19
|
+
23: "Telnet",
|
|
20
|
+
111: "RPC",
|
|
21
|
+
2049: "NFS",
|
|
22
|
+
5672: "RabbitMQ",
|
|
23
|
+
15672: "RabbitMQ management",
|
|
24
|
+
9092: "Kafka",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Ports:
|
|
29
|
+
def __init__(self, conn):
|
|
30
|
+
self.conn = conn
|
|
31
|
+
|
|
32
|
+
def get_open_ports(self):
|
|
33
|
+
output = self.conn.run("ss -tlnpu")
|
|
34
|
+
tcp, udp = [], []
|
|
35
|
+
for line in output.splitlines()[1:]:
|
|
36
|
+
parts = line.split()
|
|
37
|
+
if len(parts) < 5:
|
|
38
|
+
continue
|
|
39
|
+
proto = parts[0]
|
|
40
|
+
addr = parts[4]
|
|
41
|
+
port = int(addr.rsplit(":", 1)[-1])
|
|
42
|
+
if "*" in addr or "0.0.0.0" in addr or ":::" in addr:
|
|
43
|
+
(tcp if "tcp" in proto else udp).append(port)
|
|
44
|
+
return sorted(set(tcp)), sorted(set(udp))
|
|
45
|
+
|
|
46
|
+
def check_vulnerable_ports(self):
|
|
47
|
+
tcp, udp = self.get_open_ports()
|
|
48
|
+
vuln_tcp = {p: SHOULD_NOT_BE_OPEN[p] for p in tcp if p in SHOULD_NOT_BE_OPEN}
|
|
49
|
+
vuln_udp = {p: SHOULD_NOT_BE_OPEN[p] for p in udp if p in SHOULD_NOT_BE_OPEN}
|
|
50
|
+
|
|
51
|
+
findings = []
|
|
52
|
+
for port, name in vuln_tcp.items():
|
|
53
|
+
findings.append(("critical", f"TCP {port} ({name}) is exposed to the internet"))
|
|
54
|
+
for port, name in vuln_udp.items():
|
|
55
|
+
findings.append(("critical", f"UDP {port} ({name}) is exposed to the internet"))
|
|
56
|
+
if not findings:
|
|
57
|
+
findings.append(("ok", "No dangerous open ports detected"))
|
|
58
|
+
return findings
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class SSHConfig:
|
|
2
|
+
def __init__(self, conn):
|
|
3
|
+
self.conn = conn
|
|
4
|
+
|
|
5
|
+
def _get(self, key: str) -> str:
|
|
6
|
+
return self.conn.run(f"sshd -T 2>/dev/null | grep -i '^{key} '").split()[-1].lower()
|
|
7
|
+
|
|
8
|
+
def check(self):
|
|
9
|
+
findings = []
|
|
10
|
+
|
|
11
|
+
checks = {
|
|
12
|
+
"permitrootlogin": ("prohibit-password", "warn", "Root login allowed via password"),
|
|
13
|
+
"passwordauthentication": ("no", "warn", "Password authentication is enabled"),
|
|
14
|
+
"permitemptypasswords": ("no", "critical", "Empty passwords are permitted"),
|
|
15
|
+
"x11forwarding": ("no", "warn", "X11 forwarding is enabled"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for key, (safe_val, severity, msg) in checks.items():
|
|
19
|
+
try:
|
|
20
|
+
val = self._get(key)
|
|
21
|
+
if val != safe_val:
|
|
22
|
+
findings.append((severity, f"{msg} ({key} = {val})"))
|
|
23
|
+
else:
|
|
24
|
+
findings.append(("ok", f"{key} = {val}"))
|
|
25
|
+
except (IndexError, Exception):
|
|
26
|
+
findings.append(("warn", f"Could not retrieve {key}"))
|
|
27
|
+
|
|
28
|
+
port = self.conn.run("sshd -T 2>/dev/null | grep -i '^port '").split()[-1]
|
|
29
|
+
if port == "22":
|
|
30
|
+
findings.append(("warn", "SSH is running on default port 22"))
|
|
31
|
+
else:
|
|
32
|
+
findings.append(("ok", f"SSH is running on non-default port {port}"))
|
|
33
|
+
|
|
34
|
+
return findings
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import click
|
|
3
|
+
from .connector import Connector
|
|
4
|
+
from .checks.ports import Ports
|
|
5
|
+
from .checks.firewall import Firewall
|
|
6
|
+
from .checks.ssh_config import SSHConfig
|
|
7
|
+
from .checks.persistence import Persistence
|
|
8
|
+
from .reporter import Reporter
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.option("--host", required=True, help="Server IP or hostname")
|
|
17
|
+
@click.option("--user", default="root", help="SSH user (default: root)")
|
|
18
|
+
@click.option("--key", default=None, help="Path to private key")
|
|
19
|
+
@click.option("--password", default=None, help="SSH password")
|
|
20
|
+
@click.option("--port", default=22, show_default=True, help="SSH port")
|
|
21
|
+
@click.option("--json", "as_json", is_flag=True, help="Output results as JSON")
|
|
22
|
+
def scan(host, user, key, password, port, as_json):
|
|
23
|
+
"""Scan a remote server for security vulnerabilities and suspicious activity."""
|
|
24
|
+
with Progress(
|
|
25
|
+
SpinnerColumn(),
|
|
26
|
+
TextColumn("[progress.description]{task.description}"),
|
|
27
|
+
transient=True,
|
|
28
|
+
) as progress:
|
|
29
|
+
task = progress.add_task(f"Connecting to {host}...", total=4)
|
|
30
|
+
|
|
31
|
+
with Connector(host, user, key, password, port) as conn:
|
|
32
|
+
progress.update(task, description=f"[{host}] Checking ports...")
|
|
33
|
+
ports = Ports(conn).check_vulnerable_ports()
|
|
34
|
+
progress.advance(task)
|
|
35
|
+
|
|
36
|
+
progress.update(task, description=f"[{host}] Checking firewall...")
|
|
37
|
+
firewall = Firewall(conn).check()
|
|
38
|
+
progress.advance(task)
|
|
39
|
+
|
|
40
|
+
progress.update(task, description=f"[{host}] Checking SSH config...")
|
|
41
|
+
ssh = SSHConfig(conn).check()
|
|
42
|
+
progress.advance(task)
|
|
43
|
+
|
|
44
|
+
progress.update(task, description=f"[{host}] Checking persistence...")
|
|
45
|
+
persistence = Persistence(conn).check()
|
|
46
|
+
progress.advance(task)
|
|
47
|
+
|
|
48
|
+
results = {
|
|
49
|
+
"ports": ports,
|
|
50
|
+
"firewall": firewall,
|
|
51
|
+
"ssh_config": ssh,
|
|
52
|
+
"persistence": persistence,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if as_json:
|
|
56
|
+
click.echo(json.dumps({host: {"status": "ok", "results": results}}, indent=2))
|
|
57
|
+
else:
|
|
58
|
+
Reporter(host, results).render()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
scan()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import paramiko
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Connector:
|
|
5
|
+
def __init__(self, host: str, user: str = "root", key_path: str = None, password: str = None, port: int = 22):
|
|
6
|
+
self.host = host
|
|
7
|
+
self.user = user
|
|
8
|
+
self.key_path = key_path
|
|
9
|
+
self.password = password
|
|
10
|
+
self.port = port
|
|
11
|
+
self.client = None
|
|
12
|
+
|
|
13
|
+
def connect(self):
|
|
14
|
+
self.client = paramiko.SSHClient()
|
|
15
|
+
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
16
|
+
self.client.connect(
|
|
17
|
+
hostname=self.host,
|
|
18
|
+
port=self.port,
|
|
19
|
+
username=self.user,
|
|
20
|
+
key_filename=self.key_path,
|
|
21
|
+
password=self.password,
|
|
22
|
+
)
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def run(self, cmd: str) -> str:
|
|
26
|
+
_, stdout, _ = self.client.exec_command(cmd)
|
|
27
|
+
return stdout.read().decode().strip()
|
|
28
|
+
|
|
29
|
+
def disconnect(self):
|
|
30
|
+
if self.client:
|
|
31
|
+
self.client.close()
|
|
32
|
+
|
|
33
|
+
def __enter__(self):
|
|
34
|
+
return self.connect()
|
|
35
|
+
|
|
36
|
+
def __exit__(self, *_):
|
|
37
|
+
self.disconnect()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
from rich import box
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
ICONS = {"ok": "✓", "warn": "⚠", "critical": "✗"}
|
|
9
|
+
COLORS = {"ok": "green", "warn": "yellow", "critical": "red"}
|
|
10
|
+
SCORES = {"ok": 0, "warn": 1, "critical": 3}
|
|
11
|
+
|
|
12
|
+
SECTION_NAMES = {
|
|
13
|
+
"ports": "Ports",
|
|
14
|
+
"firewall": "Firewall",
|
|
15
|
+
"ssh_config": "SSH Config",
|
|
16
|
+
"persistence": "Malware / Persistence",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Reporter:
|
|
21
|
+
def __init__(self, host: str, results: dict):
|
|
22
|
+
self.host = host
|
|
23
|
+
self.results = results
|
|
24
|
+
|
|
25
|
+
def render(self):
|
|
26
|
+
console.print(f"\n[bold]serverscout report — {self.host}[/bold]\n")
|
|
27
|
+
|
|
28
|
+
total_score = 0
|
|
29
|
+
|
|
30
|
+
for section, findings in self.results.items():
|
|
31
|
+
table = Table(
|
|
32
|
+
title=SECTION_NAMES.get(section, section),
|
|
33
|
+
box=box.SIMPLE_HEAVY,
|
|
34
|
+
show_header=False,
|
|
35
|
+
padding=(0, 1),
|
|
36
|
+
)
|
|
37
|
+
table.add_column("", width=2)
|
|
38
|
+
table.add_column("Description")
|
|
39
|
+
|
|
40
|
+
for severity, msg in findings:
|
|
41
|
+
color = COLORS[severity]
|
|
42
|
+
icon = ICONS[severity]
|
|
43
|
+
table.add_row(
|
|
44
|
+
Text(icon, style=f"bold {color}"),
|
|
45
|
+
Text(msg, style=color if severity != "ok" else "default"),
|
|
46
|
+
)
|
|
47
|
+
total_score += SCORES[severity]
|
|
48
|
+
|
|
49
|
+
console.print(table)
|
|
50
|
+
|
|
51
|
+
if total_score == 0:
|
|
52
|
+
verdict = "[bold green]All clean[/bold green]"
|
|
53
|
+
elif total_score <= 3:
|
|
54
|
+
verdict = "[bold yellow]Minor issues found[/bold yellow]"
|
|
55
|
+
elif total_score <= 8:
|
|
56
|
+
verdict = "[bold red]Needs attention[/bold red]"
|
|
57
|
+
else:
|
|
58
|
+
verdict = "[bold red on white]CRITICAL[/bold red on white]"
|
|
59
|
+
|
|
60
|
+
console.print(f"Total score: {total_score} — {verdict}\n")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serverscout
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight SSH-based security scanner for Linux servers
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: paramiko
|
|
9
|
+
Requires-Dist: click
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
|
|
12
|
+
# serverscout
|
|
13
|
+
|
|
14
|
+
Lightweight SSH-based security scanner for Linux servers.
|
|
15
|
+
|
|
16
|
+
Checks open ports, firewall rules, SSH config, and malware persistence — no agent required.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install serverscout
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## CLI usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
serverscout --host 1.2.3.4 --key ~/.ssh/id_rsa
|
|
28
|
+
serverscout --host 1.2.3.4 --password mypass
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Python usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from serverscout import scan
|
|
35
|
+
|
|
36
|
+
results = scan([
|
|
37
|
+
{"host": "1.2.3.4", "key": "~/.ssh/id_rsa"},
|
|
38
|
+
{"host": "5.6.7.8", "password": "mypass"},
|
|
39
|
+
])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Disable Rich output and process results yourself:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
results = scan(servers, report=False)
|
|
46
|
+
|
|
47
|
+
for host, data in results.items():
|
|
48
|
+
if data["status"] == "error":
|
|
49
|
+
print(f"{host}: connection failed — {data['error']}")
|
|
50
|
+
continue
|
|
51
|
+
criticals = [
|
|
52
|
+
msg for section in data["results"].values()
|
|
53
|
+
for severity, msg in section
|
|
54
|
+
if severity == "critical"
|
|
55
|
+
]
|
|
56
|
+
if criticals:
|
|
57
|
+
print(f"{host}: {len(criticals)} critical issues")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Parallel scanning
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from serverscout import scan
|
|
64
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
65
|
+
|
|
66
|
+
servers = [{"host": f"10.0.0.{i}", "key": "~/.ssh/id_rsa"} for i in range(1, 20)]
|
|
67
|
+
|
|
68
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
69
|
+
futures = [executor.submit(scan, [s], report=False) for s in servers]
|
|
70
|
+
results = {}
|
|
71
|
+
for f in futures:
|
|
72
|
+
results.update(f.result())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What it checks
|
|
76
|
+
|
|
77
|
+
| Section | Checks |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Ports | Dangerous ports exposed to the internet (Redis, Postgres, MongoDB, etc.) |
|
|
80
|
+
| Firewall | iptables/nftables rules, fail2ban |
|
|
81
|
+
| SSH Config | Root login, password auth, empty passwords, X11 forwarding |
|
|
82
|
+
| Persistence | Executables in /tmp, postgres crontab, known malware processes, suspicious outgoing connections |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
serverscout/__init__.py
|
|
4
|
+
serverscout/cli.py
|
|
5
|
+
serverscout/connector.py
|
|
6
|
+
serverscout/reporter.py
|
|
7
|
+
serverscout.egg-info/PKG-INFO
|
|
8
|
+
serverscout.egg-info/SOURCES.txt
|
|
9
|
+
serverscout.egg-info/dependency_links.txt
|
|
10
|
+
serverscout.egg-info/entry_points.txt
|
|
11
|
+
serverscout.egg-info/requires.txt
|
|
12
|
+
serverscout.egg-info/top_level.txt
|
|
13
|
+
serverscout/checks/__init__.py
|
|
14
|
+
serverscout/checks/firewall.py
|
|
15
|
+
serverscout/checks/persistence.py
|
|
16
|
+
serverscout/checks/ports.py
|
|
17
|
+
serverscout/checks/ssh_config.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|