pentesty-agent 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.
- pentesty_agent-0.1.0/PKG-INFO +10 -0
- pentesty_agent-0.1.0/pentesty_agent/__init__.py +1 -0
- pentesty_agent-0.1.0/pentesty_agent/__main__.py +3 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/__init__.py +0 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/_patterns.py +22 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/base.py +10 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/docker/__init__.py +0 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/docker/app_logs.py +66 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/docker/env_vars.py +37 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/docker/network.py +111 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/docker/processes.py +11 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/linux/__init__.py +0 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/linux/auth_log.py +65 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/linux/fim.py +72 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/linux/ports.py +76 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/linux/processes.py +64 -0
- pentesty_agent-0.1.0/pentesty_agent/collectors/linux/web_logs.py +74 -0
- pentesty_agent-0.1.0/pentesty_agent/config.py +42 -0
- pentesty_agent-0.1.0/pentesty_agent/main.py +129 -0
- pentesty_agent-0.1.0/pentesty_agent/models.py +47 -0
- pentesty_agent-0.1.0/pentesty_agent/shipper.py +56 -0
- pentesty_agent-0.1.0/pentesty_agent/state.py +43 -0
- pentesty_agent-0.1.0/pentesty_agent.egg-info/PKG-INFO +10 -0
- pentesty_agent-0.1.0/pentesty_agent.egg-info/SOURCES.txt +29 -0
- pentesty_agent-0.1.0/pentesty_agent.egg-info/dependency_links.txt +1 -0
- pentesty_agent-0.1.0/pentesty_agent.egg-info/entry_points.txt +2 -0
- pentesty_agent-0.1.0/pentesty_agent.egg-info/requires.txt +7 -0
- pentesty_agent-0.1.0/pentesty_agent.egg-info/top_level.txt +1 -0
- pentesty_agent-0.1.0/pyproject.toml +19 -0
- pentesty_agent-0.1.0/setup.cfg +4 -0
- pentesty_agent-0.1.0/tests/test_agent_smoke.py +86 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pentesty-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pentesty security monitoring agent
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: psutil>=6.0.0
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0.0
|
|
9
|
+
Requires-Dist: watchdog>=4.0.0
|
|
10
|
+
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
ATTACK_PATTERNS: dict[str, re.Pattern] = {
|
|
6
|
+
"sqli": re.compile(
|
|
7
|
+
r"UNION\s+SELECT|'[\s]*OR[\s]*[\d']+\s*=|--[\s]*$|sleep\s*\(|benchmark\s*\(",
|
|
8
|
+
re.IGNORECASE,
|
|
9
|
+
),
|
|
10
|
+
"lfi": re.compile(
|
|
11
|
+
r"\.\.(?:/|%2f).*\.\.(?:/|%2f)|/etc/passwd|/proc/self",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
),
|
|
14
|
+
"xss": re.compile(
|
|
15
|
+
r"<script|javascript:|onerror\s*=|onload\s*=",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
),
|
|
18
|
+
"path_traversal": re.compile(
|
|
19
|
+
r"%2e%2e|\.\.%252f",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
),
|
|
22
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pentesty_agent.collectors._patterns import ATTACK_PATTERNS
|
|
8
|
+
from pentesty_agent.collectors.base import Collector
|
|
9
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
10
|
+
from pentesty_agent.state import AgentState
|
|
11
|
+
|
|
12
|
+
_APP_LOG_PATH = Path("/proc/1/fd/1")
|
|
13
|
+
_STATE_KEY_OFFSET = "app_logs_offset"
|
|
14
|
+
_STATE_KEY_ERRORS = "app_error_count"
|
|
15
|
+
_ERROR_RE = re.compile(r"\b(ERROR|Exception|Traceback)\b")
|
|
16
|
+
_ERROR_SPIKE_THRESHOLD = 5
|
|
17
|
+
_MAX_LINES_PER_CYCLE = 10_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AppLogsCollector(Collector):
|
|
22
|
+
log_path: Path = field(default_factory=lambda: _APP_LOG_PATH)
|
|
23
|
+
state: AgentState = field(default_factory=AgentState)
|
|
24
|
+
|
|
25
|
+
def collect(self) -> list[AgentEvent]:
|
|
26
|
+
try:
|
|
27
|
+
with self.log_path.open("rb") as fh:
|
|
28
|
+
offset = self.state.get(_STATE_KEY_OFFSET, 0)
|
|
29
|
+
fh.seek(offset)
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
for raw in fh:
|
|
32
|
+
lines.append(raw.decode("utf-8", errors="replace"))
|
|
33
|
+
if len(lines) >= _MAX_LINES_PER_CYCLE:
|
|
34
|
+
break
|
|
35
|
+
self.state.set(_STATE_KEY_OFFSET, fh.tell())
|
|
36
|
+
except (PermissionError, OSError):
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
events: list[AgentEvent] = []
|
|
40
|
+
error_count: int = self.state.get(_STATE_KEY_ERRORS, 0)
|
|
41
|
+
|
|
42
|
+
for line in lines:
|
|
43
|
+
if _ERROR_RE.search(line):
|
|
44
|
+
error_count += 1
|
|
45
|
+
if error_count >= _ERROR_SPIKE_THRESHOLD:
|
|
46
|
+
events.append(AgentEvent(
|
|
47
|
+
event_type="app_error_spike",
|
|
48
|
+
severity=Severity.medium,
|
|
49
|
+
payload={
|
|
50
|
+
"count": error_count,
|
|
51
|
+
"threshold": _ERROR_SPIKE_THRESHOLD,
|
|
52
|
+
},
|
|
53
|
+
))
|
|
54
|
+
error_count = 0
|
|
55
|
+
|
|
56
|
+
for attack_type, pattern in ATTACK_PATTERNS.items():
|
|
57
|
+
if pattern.search(line):
|
|
58
|
+
events.append(AgentEvent(
|
|
59
|
+
event_type="web_attack_attempt",
|
|
60
|
+
severity=Severity.medium,
|
|
61
|
+
payload={"attack_type": attack_type, "raw": line.strip()[:300]},
|
|
62
|
+
))
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
self.state.set(_STATE_KEY_ERRORS, error_count)
|
|
66
|
+
return events
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pentesty_agent.collectors.base import Collector
|
|
8
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
9
|
+
from pentesty_agent.state import AgentState
|
|
10
|
+
|
|
11
|
+
_ENVIRON_PATH = Path("/proc/1/environ")
|
|
12
|
+
_SECRET_RE = re.compile(r"(SECRET|KEY|TOKEN|PASSWORD|PRIVATE)", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class EnvVarsCollector(Collector):
|
|
17
|
+
environ_path: Path = field(default_factory=lambda: _ENVIRON_PATH)
|
|
18
|
+
state: AgentState = field(default_factory=AgentState)
|
|
19
|
+
|
|
20
|
+
def collect(self) -> list[AgentEvent]:
|
|
21
|
+
try:
|
|
22
|
+
raw = self.environ_path.read_bytes()
|
|
23
|
+
except (PermissionError, OSError):
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
events: list[AgentEvent] = []
|
|
27
|
+
for entry in raw.split(b"\x00"):
|
|
28
|
+
if b"=" not in entry:
|
|
29
|
+
continue
|
|
30
|
+
name = entry.split(b"=", 1)[0].decode("utf-8", errors="replace")
|
|
31
|
+
if _SECRET_RE.search(name):
|
|
32
|
+
events.append(AgentEvent(
|
|
33
|
+
event_type="secret_in_env",
|
|
34
|
+
severity=Severity.medium,
|
|
35
|
+
payload={"var_name": name},
|
|
36
|
+
))
|
|
37
|
+
return events
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pentesty_agent.collectors.base import Collector
|
|
11
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
12
|
+
from pentesty_agent.state import AgentState
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_ALLOWED_PORTS = {80, 443, 5432, 6379, 27017}
|
|
17
|
+
|
|
18
|
+
_RFC1918_V4 = [
|
|
19
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
20
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
21
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
22
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
23
|
+
ipaddress.ip_network("0.0.0.0/8"),
|
|
24
|
+
ipaddress.ip_network("169.254.0.0/16"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_RFC1918_V6 = [
|
|
28
|
+
ipaddress.ip_network("::1/128"),
|
|
29
|
+
ipaddress.ip_network("fc00::/7"),
|
|
30
|
+
ipaddress.ip_network("fe80::/10"),
|
|
31
|
+
ipaddress.ip_network("::ffff:0:0/96"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_private(ip: str) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
addr = ipaddress.ip_address(ip)
|
|
38
|
+
if isinstance(addr, ipaddress.IPv4Address):
|
|
39
|
+
return any(addr in net for net in _RFC1918_V4)
|
|
40
|
+
return any(addr in net for net in _RFC1918_V6)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _hex_to_ip(hex_str: str) -> str:
|
|
46
|
+
if len(hex_str) == 8:
|
|
47
|
+
packed = struct.pack("<I", int(hex_str, 16))
|
|
48
|
+
return socket.inet_ntoa(packed)
|
|
49
|
+
if len(hex_str) == 32:
|
|
50
|
+
# IPv4-mapped: first 24 chars are zeros + FFFF prefix
|
|
51
|
+
if hex_str[:24].upper() in ("00000000000000000000FFFF",):
|
|
52
|
+
packed = struct.pack("<I", int(hex_str[24:], 16))
|
|
53
|
+
return socket.inet_ntoa(packed)
|
|
54
|
+
# Native IPv6: 4 LE 32-bit words
|
|
55
|
+
parts = [hex_str[i:i + 8] for i in range(0, 32, 8)]
|
|
56
|
+
packed = b"".join(struct.pack("<I", int(p, 16)) for p in parts)
|
|
57
|
+
return socket.inet_ntop(socket.AF_INET6, packed)
|
|
58
|
+
raise ValueError(f"unexpected hex IP length: {len(hex_str)}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_proc_net_tcp(path: Path) -> list[tuple[str, int, int]]:
|
|
62
|
+
"""Returns (remote_ip, remote_port, local_port) for ESTABLISHED connections."""
|
|
63
|
+
results = []
|
|
64
|
+
try:
|
|
65
|
+
lines = path.read_text().splitlines()[1:]
|
|
66
|
+
except OSError:
|
|
67
|
+
return results
|
|
68
|
+
for line in lines:
|
|
69
|
+
parts = line.split()
|
|
70
|
+
if len(parts) < 4:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
state = int(parts[3], 16)
|
|
74
|
+
if state != 1: # 1 = ESTABLISHED
|
|
75
|
+
continue
|
|
76
|
+
local_hex, local_port_hex = parts[1].rsplit(":", 1)
|
|
77
|
+
remote_hex, remote_port_hex = parts[2].rsplit(":", 1)
|
|
78
|
+
local_port = int(local_port_hex, 16)
|
|
79
|
+
remote_port = int(remote_port_hex, 16)
|
|
80
|
+
remote_ip = _hex_to_ip(remote_hex)
|
|
81
|
+
results.append((remote_ip, remote_port, local_port))
|
|
82
|
+
except (ValueError, IndexError, struct.error):
|
|
83
|
+
continue
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class NetworkCollector(Collector):
|
|
89
|
+
state: AgentState = field(default_factory=AgentState)
|
|
90
|
+
|
|
91
|
+
def collect(self) -> list[AgentEvent]:
|
|
92
|
+
connections: list[tuple[str, int, int]] = []
|
|
93
|
+
for proc_file in [Path("/proc/net/tcp"), Path("/proc/net/tcp6")]:
|
|
94
|
+
connections.extend(_parse_proc_net_tcp(proc_file))
|
|
95
|
+
|
|
96
|
+
events: list[AgentEvent] = []
|
|
97
|
+
for remote_ip, remote_port, local_port in connections:
|
|
98
|
+
if remote_port in _ALLOWED_PORTS:
|
|
99
|
+
continue
|
|
100
|
+
if _is_private(remote_ip):
|
|
101
|
+
continue
|
|
102
|
+
events.append(AgentEvent(
|
|
103
|
+
event_type="suspicious_outbound",
|
|
104
|
+
severity=Severity.high,
|
|
105
|
+
payload={
|
|
106
|
+
"remote_ip": remote_ip,
|
|
107
|
+
"remote_port": remote_port,
|
|
108
|
+
"local_port": local_port,
|
|
109
|
+
},
|
|
110
|
+
))
|
|
111
|
+
return events
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from pentesty_agent.collectors.linux.processes import ProcessCollector as _LinuxProcessCollector
|
|
6
|
+
from pentesty_agent.state import AgentState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DockerProcessCollector(_LinuxProcessCollector):
|
|
11
|
+
state: AgentState = field(default_factory=AgentState)
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pentesty_agent.collectors.base import Collector
|
|
8
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
9
|
+
from pentesty_agent.state import AgentState
|
|
10
|
+
|
|
11
|
+
_SSH_RE = re.compile(r"Failed password for .+ from (\S+) port")
|
|
12
|
+
_SUDO_RE = re.compile(r"sudo:.*FAILED")
|
|
13
|
+
_USER_RE = re.compile(r"useradd\[|new user:")
|
|
14
|
+
_SUDO_USER = re.compile(r"sudo:\s+(\S+)\s+:")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AuthLogCollector(Collector):
|
|
19
|
+
log_path: Path = field(default_factory=lambda: Path("/var/log/auth.log"))
|
|
20
|
+
state: AgentState = field(default_factory=AgentState)
|
|
21
|
+
|
|
22
|
+
_STATE_KEY = "auth_log_offset"
|
|
23
|
+
|
|
24
|
+
def collect(self) -> list[AgentEvent]:
|
|
25
|
+
if not self.log_path.exists():
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
offset = self.state.get(self._STATE_KEY, 0)
|
|
29
|
+
events: list[AgentEvent] = []
|
|
30
|
+
|
|
31
|
+
with self.log_path.open("rb") as fh:
|
|
32
|
+
fh.seek(offset)
|
|
33
|
+
for raw in fh:
|
|
34
|
+
line = raw.decode("utf-8", errors="replace")
|
|
35
|
+
event = self._parse_line(line)
|
|
36
|
+
if event:
|
|
37
|
+
events.append(event)
|
|
38
|
+
new_offset = fh.tell()
|
|
39
|
+
|
|
40
|
+
self.state.set(self._STATE_KEY, new_offset)
|
|
41
|
+
return events
|
|
42
|
+
|
|
43
|
+
def _parse_line(self, line: str) -> AgentEvent | None:
|
|
44
|
+
if m := _SSH_RE.search(line):
|
|
45
|
+
return AgentEvent(
|
|
46
|
+
event_type="ssh_brute_force",
|
|
47
|
+
severity=Severity.high,
|
|
48
|
+
payload={"source_ip": m.group(1), "raw": line.strip()},
|
|
49
|
+
)
|
|
50
|
+
if _SUDO_RE.search(line):
|
|
51
|
+
user = ""
|
|
52
|
+
if um := _SUDO_USER.search(line):
|
|
53
|
+
user = um.group(1)
|
|
54
|
+
return AgentEvent(
|
|
55
|
+
event_type="sudo_failure",
|
|
56
|
+
severity=Severity.medium,
|
|
57
|
+
payload={"user": user, "raw": line.strip()},
|
|
58
|
+
)
|
|
59
|
+
if _USER_RE.search(line):
|
|
60
|
+
return AgentEvent(
|
|
61
|
+
event_type="new_user_created",
|
|
62
|
+
severity=Severity.high,
|
|
63
|
+
payload={"raw": line.strip()},
|
|
64
|
+
)
|
|
65
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pentesty_agent.collectors.base import Collector
|
|
8
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
9
|
+
from pentesty_agent.state import AgentState
|
|
10
|
+
|
|
11
|
+
_DEFAULT_WATCH = [
|
|
12
|
+
Path("/etc/passwd"),
|
|
13
|
+
Path("/etc/shadow"),
|
|
14
|
+
Path("/etc/sudoers"),
|
|
15
|
+
Path.home() / ".ssh" / "authorized_keys",
|
|
16
|
+
Path("/etc/crontab"),
|
|
17
|
+
Path("/var/spool/cron"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_STATE_KEY = "fim_hashes"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sha256(path: Path) -> str | None:
|
|
24
|
+
try:
|
|
25
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
26
|
+
except (OSError, PermissionError):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _expand_paths(paths: list[Path]) -> list[Path]:
|
|
31
|
+
result: list[Path] = []
|
|
32
|
+
for p in paths:
|
|
33
|
+
try:
|
|
34
|
+
if "*" in str(p):
|
|
35
|
+
result.extend(sorted(p.parent.glob(p.name)))
|
|
36
|
+
elif p.is_dir():
|
|
37
|
+
result.extend(sorted(p.iterdir()))
|
|
38
|
+
else:
|
|
39
|
+
result.append(p)
|
|
40
|
+
except (OSError, PermissionError):
|
|
41
|
+
pass
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class FimCollector(Collector):
|
|
47
|
+
watch_paths: list[Path] = field(default_factory=lambda: list(_DEFAULT_WATCH))
|
|
48
|
+
state: AgentState = field(default_factory=AgentState)
|
|
49
|
+
|
|
50
|
+
def collect(self) -> list[AgentEvent]:
|
|
51
|
+
stored: dict[str, str | None] = self.state.get(_STATE_KEY, {})
|
|
52
|
+
current: dict[str, str | None] = {}
|
|
53
|
+
events: list[AgentEvent] = []
|
|
54
|
+
|
|
55
|
+
for path in _expand_paths(self.watch_paths):
|
|
56
|
+
key = str(path)
|
|
57
|
+
new_hash = _sha256(path)
|
|
58
|
+
current[key] = new_hash
|
|
59
|
+
|
|
60
|
+
if new_hash is None:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
old_hash = stored.get(key)
|
|
64
|
+
if old_hash != new_hash:
|
|
65
|
+
events.append(AgentEvent(
|
|
66
|
+
event_type="fim_change",
|
|
67
|
+
severity=Severity.critical,
|
|
68
|
+
payload={"file": key, "old_hash": old_hash, "new_hash": new_hash},
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
self.state.set(_STATE_KEY, {k: v for k, v in current.items() if v is not None})
|
|
72
|
+
return events
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from pentesty_agent.collectors.base import Collector
|
|
9
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
10
|
+
from pentesty_agent.state import AgentState
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_STATE_KEY = "ports_snapshot"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_ss(output: bytes) -> dict[int, dict]:
|
|
18
|
+
ports: dict[int, dict] = {}
|
|
19
|
+
for line in output.decode("utf-8", errors="replace").splitlines():
|
|
20
|
+
if not line.startswith("tcp"):
|
|
21
|
+
continue
|
|
22
|
+
pid: int | None = None
|
|
23
|
+
name: str | None = None
|
|
24
|
+
pid_m = re.search(r"pid=(\d+)", line)
|
|
25
|
+
name_m = re.search(r'"([^"]+)"', line)
|
|
26
|
+
if pid_m:
|
|
27
|
+
pid = int(pid_m.group(1))
|
|
28
|
+
if name_m:
|
|
29
|
+
name = name_m.group(1)
|
|
30
|
+
parts = line.split()
|
|
31
|
+
for part in parts:
|
|
32
|
+
if ":" in part:
|
|
33
|
+
port_str = part.rsplit(":", 1)[-1]
|
|
34
|
+
if port_str.isdigit():
|
|
35
|
+
port = int(port_str)
|
|
36
|
+
if 1 <= port <= 65535:
|
|
37
|
+
ports[port] = {"pid": pid, "process_name": name}
|
|
38
|
+
break
|
|
39
|
+
return ports
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PortsCollector(Collector):
|
|
44
|
+
state: AgentState = field(default_factory=AgentState)
|
|
45
|
+
|
|
46
|
+
def collect(self) -> list[AgentEvent]:
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["ss", "-tlnp"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
timeout=5,
|
|
52
|
+
)
|
|
53
|
+
current = _parse_ss(result.stdout)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.warning("ss failed: %s", exc)
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
previous: dict[str, dict] = self.state.get(_STATE_KEY, {})
|
|
59
|
+
prev_ports = {int(k) for k in previous}
|
|
60
|
+
new_ports = set(current) - prev_ports
|
|
61
|
+
|
|
62
|
+
self.state.set(_STATE_KEY, {str(p): v for p, v in current.items()})
|
|
63
|
+
|
|
64
|
+
events: list[AgentEvent] = []
|
|
65
|
+
for port in sorted(new_ports):
|
|
66
|
+
info = current[port]
|
|
67
|
+
events.append(AgentEvent(
|
|
68
|
+
event_type="new_port_open",
|
|
69
|
+
severity=Severity.high,
|
|
70
|
+
payload={
|
|
71
|
+
"port": port,
|
|
72
|
+
"pid": info["pid"],
|
|
73
|
+
"process_name": info["process_name"],
|
|
74
|
+
},
|
|
75
|
+
))
|
|
76
|
+
return events
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from pentesty_agent.collectors.base import Collector
|
|
9
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
10
|
+
from pentesty_agent.state import AgentState
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_SUSPICIOUS_PATTERNS = [
|
|
15
|
+
re.compile(r"\bnc\b"),
|
|
16
|
+
re.compile(r"\bncat\b"),
|
|
17
|
+
re.compile(r"\bnmap\b"),
|
|
18
|
+
re.compile(r"python\s+-c\b"),
|
|
19
|
+
re.compile(r"bash\s+-i\b"),
|
|
20
|
+
re.compile(r"\bbase64\b"),
|
|
21
|
+
re.compile(r"curl\s+\S+\s*\|"),
|
|
22
|
+
re.compile(r"wget\s+\S+\s*\|"),
|
|
23
|
+
re.compile(r"perl\s+-e\b"),
|
|
24
|
+
re.compile(r"ruby\s+-e\b"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProcessCollector(Collector):
|
|
30
|
+
state: AgentState = field(default_factory=AgentState)
|
|
31
|
+
|
|
32
|
+
def collect(self) -> list[AgentEvent]:
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["ps", "aux"],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
timeout=5,
|
|
38
|
+
)
|
|
39
|
+
output = result.stdout.decode("utf-8", errors="replace")
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
logger.warning("ps failed: %s", exc)
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
events: list[AgentEvent] = []
|
|
45
|
+
for line in output.splitlines()[1:]: # skip header
|
|
46
|
+
parts = line.split(None, 10)
|
|
47
|
+
if len(parts) < 11:
|
|
48
|
+
continue
|
|
49
|
+
pid_str = parts[1]
|
|
50
|
+
cmdline = parts[10][:200]
|
|
51
|
+
|
|
52
|
+
if not pid_str.isdigit():
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
for pattern in _SUSPICIOUS_PATTERNS:
|
|
56
|
+
if pattern.search(cmdline):
|
|
57
|
+
events.append(AgentEvent(
|
|
58
|
+
event_type="suspicious_process",
|
|
59
|
+
severity=Severity.high,
|
|
60
|
+
payload={"pid": int(pid_str), "cmdline": cmdline},
|
|
61
|
+
))
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
return events
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pentesty_agent.collectors._patterns import ATTACK_PATTERNS
|
|
8
|
+
from pentesty_agent.collectors.base import Collector
|
|
9
|
+
from pentesty_agent.models import AgentEvent, Severity
|
|
10
|
+
from pentesty_agent.state import AgentState
|
|
11
|
+
|
|
12
|
+
_DEFAULT_LOGS = [
|
|
13
|
+
Path("/var/log/nginx/access.log"),
|
|
14
|
+
Path("/var/log/apache2/access.log"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
_LOG_RE = re.compile(
|
|
18
|
+
r'^(\S+)\s+\S+\s+\S+\s+\[[^\]]+\]\s+"(\w+)\s+(.*?)\s+HTTP/[\d.]+"\s+(\d+)',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class WebLogsCollector(Collector):
|
|
24
|
+
log_paths: list[Path] = field(default_factory=lambda: list(_DEFAULT_LOGS))
|
|
25
|
+
state: AgentState = field(default_factory=AgentState)
|
|
26
|
+
|
|
27
|
+
def collect(self) -> list[AgentEvent]:
|
|
28
|
+
events: list[AgentEvent] = []
|
|
29
|
+
for log_path in self.log_paths:
|
|
30
|
+
events.extend(self._collect_one(log_path))
|
|
31
|
+
return events
|
|
32
|
+
|
|
33
|
+
def _collect_one(self, log_path: Path) -> list[AgentEvent]:
|
|
34
|
+
if not log_path.exists():
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
state_key = f"web_log_offset:{log_path}"
|
|
38
|
+
offset = self.state.get(state_key, 0)
|
|
39
|
+
events: list[AgentEvent] = []
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with log_path.open("rb") as fh:
|
|
43
|
+
fh.seek(offset)
|
|
44
|
+
for raw in fh:
|
|
45
|
+
line = raw.decode("utf-8", errors="replace")
|
|
46
|
+
event = self._parse_line(line)
|
|
47
|
+
if event:
|
|
48
|
+
events.append(event)
|
|
49
|
+
self.state.set(state_key, fh.tell())
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
return events
|
|
54
|
+
|
|
55
|
+
def _parse_line(self, line: str) -> AgentEvent | None:
|
|
56
|
+
m = _LOG_RE.match(line)
|
|
57
|
+
if not m:
|
|
58
|
+
return None
|
|
59
|
+
source_ip, method, path, status_code = m.group(1), m.group(2), m.group(3), int(m.group(4))
|
|
60
|
+
|
|
61
|
+
for attack_type, pattern in ATTACK_PATTERNS.items():
|
|
62
|
+
if pattern.search(path):
|
|
63
|
+
return AgentEvent(
|
|
64
|
+
event_type="web_attack_attempt",
|
|
65
|
+
severity=Severity.medium,
|
|
66
|
+
payload={
|
|
67
|
+
"source_ip": source_ip,
|
|
68
|
+
"method": method,
|
|
69
|
+
"path": path[:500],
|
|
70
|
+
"attack_type": attack_type,
|
|
71
|
+
"status_code": status_code,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else:
|
|
11
|
+
import tomli as tomllib # type: ignore[no-reuse-def]
|
|
12
|
+
|
|
13
|
+
_CONFIG_PATH = Path("/etc/pentesty-agent/config.toml")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AgentConfig:
|
|
18
|
+
token: str
|
|
19
|
+
backend_url: str = "https://api.pentesty.co"
|
|
20
|
+
interval_secs: int = 30
|
|
21
|
+
server_id: str = ""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def load(cls, config_path: Path = _CONFIG_PATH) -> AgentConfig:
|
|
25
|
+
"""Load from TOML file, then override with env vars (env wins)."""
|
|
26
|
+
data: dict = {}
|
|
27
|
+
if config_path.exists():
|
|
28
|
+
with open(config_path, "rb") as f:
|
|
29
|
+
raw = tomllib.load(f)
|
|
30
|
+
data = raw.get("agent", {})
|
|
31
|
+
|
|
32
|
+
token = os.environ.get("PENTESTY_TOKEN") or data.get("token", "")
|
|
33
|
+
backend_url = os.environ.get("PENTESTY_BACKEND_URL") or data.get("backend_url", "https://api.pentesty.co")
|
|
34
|
+
interval_secs = int(os.environ.get("PENTESTY_INTERVAL", data.get("interval_secs", 30)))
|
|
35
|
+
server_id = os.environ.get("PENTESTY_SERVER_ID") or data.get("server_id", "")
|
|
36
|
+
|
|
37
|
+
return cls(
|
|
38
|
+
token=token,
|
|
39
|
+
backend_url=backend_url,
|
|
40
|
+
interval_secs=interval_secs,
|
|
41
|
+
server_id=server_id,
|
|
42
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pentesty_agent.collectors.base import Collector
|
|
11
|
+
from pentesty_agent.config import AgentConfig
|
|
12
|
+
from pentesty_agent.models import EventBatch
|
|
13
|
+
from pentesty_agent.shipper import Shipper
|
|
14
|
+
from pentesty_agent.state import AgentState
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("pentesty_agent")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_args() -> argparse.Namespace:
|
|
20
|
+
p = argparse.ArgumentParser(description="Pentesty security monitoring agent")
|
|
21
|
+
p.add_argument("--token", default="", help="Agent token (overrides config/env)")
|
|
22
|
+
p.add_argument("--backend", default="", help="Backend URL (overrides config/env)")
|
|
23
|
+
p.add_argument("--dry-run", action="store_true", help="Log events without sending")
|
|
24
|
+
p.add_argument("--once", action="store_true", help="Run one collection cycle and exit")
|
|
25
|
+
return p.parse_args()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _detect_environment() -> Literal["linux", "docker", "generic"]:
|
|
29
|
+
if Path("/.dockerenv").exists():
|
|
30
|
+
return "docker"
|
|
31
|
+
if Path("/var/log/auth.log").exists() or Path("/run/systemd").exists():
|
|
32
|
+
return "linux"
|
|
33
|
+
return "generic"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_collectors(env: str, state: AgentState) -> list[Collector]:
|
|
37
|
+
if env == "linux":
|
|
38
|
+
from pentesty_agent.collectors.linux.auth_log import AuthLogCollector
|
|
39
|
+
from pentesty_agent.collectors.linux.fim import FimCollector
|
|
40
|
+
from pentesty_agent.collectors.linux.ports import PortsCollector
|
|
41
|
+
from pentesty_agent.collectors.linux.processes import ProcessCollector
|
|
42
|
+
from pentesty_agent.collectors.linux.web_logs import WebLogsCollector
|
|
43
|
+
return [
|
|
44
|
+
AuthLogCollector(state=state),
|
|
45
|
+
FimCollector(state=state),
|
|
46
|
+
PortsCollector(state=state),
|
|
47
|
+
ProcessCollector(state=state),
|
|
48
|
+
WebLogsCollector(state=state),
|
|
49
|
+
]
|
|
50
|
+
if env == "docker":
|
|
51
|
+
from pentesty_agent.collectors.docker.app_logs import AppLogsCollector
|
|
52
|
+
from pentesty_agent.collectors.docker.env_vars import EnvVarsCollector
|
|
53
|
+
from pentesty_agent.collectors.docker.network import NetworkCollector
|
|
54
|
+
from pentesty_agent.collectors.docker.processes import DockerProcessCollector
|
|
55
|
+
return [
|
|
56
|
+
AppLogsCollector(state=state),
|
|
57
|
+
DockerProcessCollector(state=state),
|
|
58
|
+
NetworkCollector(state=state),
|
|
59
|
+
EnvVarsCollector(state=state),
|
|
60
|
+
]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _collect_events(collectors: list[Collector]) -> EventBatch:
|
|
65
|
+
events = []
|
|
66
|
+
for collector in collectors:
|
|
67
|
+
try:
|
|
68
|
+
events.extend(collector.collect())
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.error("collector %s failed: %s", type(collector).__name__, exc)
|
|
71
|
+
return EventBatch(events=events)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> None:
|
|
75
|
+
logging.basicConfig(
|
|
76
|
+
level=logging.INFO,
|
|
77
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
78
|
+
)
|
|
79
|
+
args = _parse_args()
|
|
80
|
+
|
|
81
|
+
config = AgentConfig.load()
|
|
82
|
+
if args.token:
|
|
83
|
+
config.token = args.token
|
|
84
|
+
if args.backend:
|
|
85
|
+
config.backend_url = args.backend
|
|
86
|
+
|
|
87
|
+
if not config.token and not args.dry_run:
|
|
88
|
+
logger.error("No token configured. Set PENTESTY_TOKEN or pass --token.")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
shipper = Shipper(config, dry_run=args.dry_run)
|
|
92
|
+
env = _detect_environment()
|
|
93
|
+
state = AgentState()
|
|
94
|
+
collectors = _build_collectors(env, state)
|
|
95
|
+
|
|
96
|
+
logger.info(
|
|
97
|
+
"Pentesty agent starting (env=%s collectors=%d backend=%s dry_run=%s)",
|
|
98
|
+
env, len(collectors), config.backend_url, args.dry_run,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
_run_loop(shipper, collectors, config, once=args.once)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _run_loop(
|
|
105
|
+
shipper: Shipper,
|
|
106
|
+
collectors: list[Collector],
|
|
107
|
+
config: AgentConfig,
|
|
108
|
+
*,
|
|
109
|
+
once: bool,
|
|
110
|
+
) -> None:
|
|
111
|
+
while True:
|
|
112
|
+
try:
|
|
113
|
+
shipper.send_heartbeat()
|
|
114
|
+
batch = _collect_events(collectors)
|
|
115
|
+
if batch.events:
|
|
116
|
+
shipper.send_events(batch)
|
|
117
|
+
logger.info("sent %d events", len(batch.events))
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
logger.error("loop iteration failed: %s", exc)
|
|
120
|
+
|
|
121
|
+
if once:
|
|
122
|
+
logger.info("--once flag set, exiting")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
time.sleep(config.interval_secs)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, enum.Enum):
|
|
11
|
+
info = "info"
|
|
12
|
+
low = "low"
|
|
13
|
+
medium = "medium"
|
|
14
|
+
high = "high"
|
|
15
|
+
critical = "critical"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentEvent(BaseModel):
|
|
19
|
+
event_type: str
|
|
20
|
+
severity: Severity
|
|
21
|
+
payload: dict[str, Any]
|
|
22
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="after")
|
|
25
|
+
def _no_secrets_in_payload(self) -> AgentEvent:
|
|
26
|
+
forbidden = {"password", "secret", "token"}
|
|
27
|
+
for key in self.payload:
|
|
28
|
+
if key.lower() in forbidden:
|
|
29
|
+
raise ValueError(f"payload must not contain field '{key}'")
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EventBatch(BaseModel):
|
|
34
|
+
events: list[AgentEvent]
|
|
35
|
+
|
|
36
|
+
def model_dump_for_api(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"events": [
|
|
39
|
+
{
|
|
40
|
+
"event_type": e.event_type,
|
|
41
|
+
"severity": e.severity.value,
|
|
42
|
+
"payload": e.payload,
|
|
43
|
+
"timestamp": e.timestamp.isoformat(),
|
|
44
|
+
}
|
|
45
|
+
for e in self.events
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from pentesty_agent.config import AgentConfig
|
|
9
|
+
from pentesty_agent.models import EventBatch
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_TIMEOUT = 15.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Shipper:
|
|
17
|
+
def __init__(self, config: AgentConfig, dry_run: bool = False) -> None:
|
|
18
|
+
self._config = config
|
|
19
|
+
self._dry_run = dry_run
|
|
20
|
+
self._headers = {"Agent-Token": config.token}
|
|
21
|
+
|
|
22
|
+
def send_heartbeat(self) -> bool:
|
|
23
|
+
if self._dry_run:
|
|
24
|
+
logger.info("[dry-run] heartbeat sent")
|
|
25
|
+
return True
|
|
26
|
+
url = f"{self._config.backend_url}/api/v1/agent/heartbeat"
|
|
27
|
+
try:
|
|
28
|
+
resp = httpx.post(url, headers=self._headers, timeout=_TIMEOUT)
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
logger.debug("heartbeat sent (status=%s)", resp.status_code)
|
|
31
|
+
return True
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
logger.warning("heartbeat failed: %s", exc)
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def send_events(self, batch: EventBatch) -> bool:
|
|
37
|
+
if not batch.events:
|
|
38
|
+
return True
|
|
39
|
+
if self._dry_run:
|
|
40
|
+
for e in batch.events:
|
|
41
|
+
logger.info("[dry-run] event: %s severity=%s payload=%s", e.event_type, e.severity.value, e.payload)
|
|
42
|
+
return True
|
|
43
|
+
url = f"{self._config.backend_url}/api/v1/agent/events"
|
|
44
|
+
try:
|
|
45
|
+
resp = httpx.post(
|
|
46
|
+
url,
|
|
47
|
+
json=batch.model_dump_for_api(),
|
|
48
|
+
headers=self._headers,
|
|
49
|
+
timeout=_TIMEOUT,
|
|
50
|
+
)
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
logger.debug("sent %d events (status=%s)", len(batch.events), resp.status_code)
|
|
53
|
+
return True
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.warning("send_events failed: %s", exc)
|
|
56
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default_state_path() -> Path:
|
|
13
|
+
if os.geteuid() == 0:
|
|
14
|
+
return Path("/var/lib/pentesty-agent/state.json")
|
|
15
|
+
return Path.home() / ".pentesty-agent" / "state.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentState:
|
|
19
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
20
|
+
self._path = path or _default_state_path()
|
|
21
|
+
self._data: dict[str, Any] = self._load()
|
|
22
|
+
|
|
23
|
+
def _load(self) -> dict[str, Any]:
|
|
24
|
+
try:
|
|
25
|
+
if self._path.exists():
|
|
26
|
+
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
logger.warning("failed to load state from %s: %s", self._path, exc)
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
def _save(self) -> None:
|
|
32
|
+
try:
|
|
33
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
self._path.write_text(json.dumps(self._data), encoding="utf-8")
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
logger.warning("failed to save state to %s: %s", self._path, exc)
|
|
37
|
+
|
|
38
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
39
|
+
return self._data.get(key, default)
|
|
40
|
+
|
|
41
|
+
def set(self, key: str, value: Any) -> None:
|
|
42
|
+
self._data[key] = value
|
|
43
|
+
self._save()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pentesty-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pentesty security monitoring agent
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: psutil>=6.0.0
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0.0
|
|
9
|
+
Requires-Dist: watchdog>=4.0.0
|
|
10
|
+
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pentesty_agent/__init__.py
|
|
3
|
+
pentesty_agent/__main__.py
|
|
4
|
+
pentesty_agent/config.py
|
|
5
|
+
pentesty_agent/main.py
|
|
6
|
+
pentesty_agent/models.py
|
|
7
|
+
pentesty_agent/shipper.py
|
|
8
|
+
pentesty_agent/state.py
|
|
9
|
+
pentesty_agent.egg-info/PKG-INFO
|
|
10
|
+
pentesty_agent.egg-info/SOURCES.txt
|
|
11
|
+
pentesty_agent.egg-info/dependency_links.txt
|
|
12
|
+
pentesty_agent.egg-info/entry_points.txt
|
|
13
|
+
pentesty_agent.egg-info/requires.txt
|
|
14
|
+
pentesty_agent.egg-info/top_level.txt
|
|
15
|
+
pentesty_agent/collectors/__init__.py
|
|
16
|
+
pentesty_agent/collectors/_patterns.py
|
|
17
|
+
pentesty_agent/collectors/base.py
|
|
18
|
+
pentesty_agent/collectors/docker/__init__.py
|
|
19
|
+
pentesty_agent/collectors/docker/app_logs.py
|
|
20
|
+
pentesty_agent/collectors/docker/env_vars.py
|
|
21
|
+
pentesty_agent/collectors/docker/network.py
|
|
22
|
+
pentesty_agent/collectors/docker/processes.py
|
|
23
|
+
pentesty_agent/collectors/linux/__init__.py
|
|
24
|
+
pentesty_agent/collectors/linux/auth_log.py
|
|
25
|
+
pentesty_agent/collectors/linux/fim.py
|
|
26
|
+
pentesty_agent/collectors/linux/ports.py
|
|
27
|
+
pentesty_agent/collectors/linux/processes.py
|
|
28
|
+
pentesty_agent/collectors/linux/web_logs.py
|
|
29
|
+
tests/test_agent_smoke.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pentesty_agent
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pentesty-agent"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pentesty security monitoring agent"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"psutil>=6.0.0",
|
|
8
|
+
"httpx>=0.27.0",
|
|
9
|
+
"pydantic>=2.0.0",
|
|
10
|
+
"watchdog>=4.0.0",
|
|
11
|
+
"tomli>=2.0.0; python_version < '3.11'",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
pentesty-agent = "pentesty_agent.main:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=68", "wheel"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from pentesty_agent.config import AgentConfig
|
|
7
|
+
from pentesty_agent.models import AgentEvent, EventBatch, Severity
|
|
8
|
+
from pentesty_agent.shipper import Shipper
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── AgentConfig ───────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def test_config_load_from_env(monkeypatch, tmp_path):
|
|
14
|
+
monkeypatch.setenv("PENTESTY_TOKEN", "tok-from-env")
|
|
15
|
+
monkeypatch.setenv("PENTESTY_BACKEND_URL", "http://custom:9000")
|
|
16
|
+
cfg = AgentConfig.load(config_path=tmp_path / "missing.toml")
|
|
17
|
+
assert cfg.token == "tok-from-env"
|
|
18
|
+
assert cfg.backend_url == "http://custom:9000"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_config_env_overrides_toml(tmp_path, monkeypatch):
|
|
22
|
+
monkeypatch.setenv("PENTESTY_TOKEN", "env-wins")
|
|
23
|
+
toml = tmp_path / "config.toml"
|
|
24
|
+
toml.write_text('[agent]\ntoken = "toml-token"\n')
|
|
25
|
+
cfg = AgentConfig.load(config_path=toml)
|
|
26
|
+
assert cfg.token == "env-wins"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_config_loads_toml_when_no_env(tmp_path, monkeypatch):
|
|
30
|
+
monkeypatch.delenv("PENTESTY_TOKEN", raising=False)
|
|
31
|
+
monkeypatch.delenv("PENTESTY_BACKEND_URL", raising=False)
|
|
32
|
+
toml = tmp_path / "config.toml"
|
|
33
|
+
toml.write_text('[agent]\ntoken = "from-toml"\nbackend_url = "http://toml:8000"\n')
|
|
34
|
+
cfg = AgentConfig.load(config_path=toml)
|
|
35
|
+
assert cfg.token == "from-toml"
|
|
36
|
+
assert cfg.backend_url == "http://toml:8000"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── AgentEvent validation ─────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def test_agent_event_rejects_secret_in_payload():
|
|
42
|
+
with pytest.raises(ValueError, match="secret"):
|
|
43
|
+
AgentEvent(event_type="test", severity=Severity.low, payload={"secret": "oops"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_agent_event_valid_payload():
|
|
47
|
+
e = AgentEvent(event_type="ssh_brute_force", severity=Severity.high, payload={"source_ip": "1.2.3.4"})
|
|
48
|
+
assert e.event_type == "ssh_brute_force"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Shipper dry-run ───────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def test_shipper_dry_run_heartbeat_does_not_call_http():
|
|
54
|
+
cfg = AgentConfig(token="tok", backend_url="http://localhost")
|
|
55
|
+
shipper = Shipper(cfg, dry_run=True)
|
|
56
|
+
result = shipper.send_heartbeat()
|
|
57
|
+
assert result is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_shipper_dry_run_send_events_logs_without_http():
|
|
61
|
+
cfg = AgentConfig(token="tok", backend_url="http://localhost")
|
|
62
|
+
shipper = Shipper(cfg, dry_run=True)
|
|
63
|
+
batch = EventBatch(events=[
|
|
64
|
+
AgentEvent(event_type="fim_change", severity=Severity.critical, payload={"file": "/etc/passwd"}),
|
|
65
|
+
])
|
|
66
|
+
result = shipper.send_events(batch)
|
|
67
|
+
assert result is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_shipper_network_failure_returns_false_not_exception():
|
|
71
|
+
cfg = AgentConfig(token="tok", backend_url="http://127.0.0.1:1") # nothing listening
|
|
72
|
+
shipper = Shipper(cfg, dry_run=False)
|
|
73
|
+
result = shipper.send_heartbeat()
|
|
74
|
+
assert result is False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── EventBatch serialisation ──────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def test_event_batch_dump_for_api():
|
|
80
|
+
batch = EventBatch(events=[
|
|
81
|
+
AgentEvent(event_type="ssh_brute_force", severity=Severity.high, payload={"ip": "1.2.3.4"}),
|
|
82
|
+
])
|
|
83
|
+
dumped = batch.model_dump_for_api()
|
|
84
|
+
assert dumped["events"][0]["event_type"] == "ssh_brute_force"
|
|
85
|
+
assert dumped["events"][0]["severity"] == "high"
|
|
86
|
+
assert "timestamp" in dumped["events"][0]
|