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.
Files changed (31) hide show
  1. pentesty_agent-0.1.0/PKG-INFO +10 -0
  2. pentesty_agent-0.1.0/pentesty_agent/__init__.py +1 -0
  3. pentesty_agent-0.1.0/pentesty_agent/__main__.py +3 -0
  4. pentesty_agent-0.1.0/pentesty_agent/collectors/__init__.py +0 -0
  5. pentesty_agent-0.1.0/pentesty_agent/collectors/_patterns.py +22 -0
  6. pentesty_agent-0.1.0/pentesty_agent/collectors/base.py +10 -0
  7. pentesty_agent-0.1.0/pentesty_agent/collectors/docker/__init__.py +0 -0
  8. pentesty_agent-0.1.0/pentesty_agent/collectors/docker/app_logs.py +66 -0
  9. pentesty_agent-0.1.0/pentesty_agent/collectors/docker/env_vars.py +37 -0
  10. pentesty_agent-0.1.0/pentesty_agent/collectors/docker/network.py +111 -0
  11. pentesty_agent-0.1.0/pentesty_agent/collectors/docker/processes.py +11 -0
  12. pentesty_agent-0.1.0/pentesty_agent/collectors/linux/__init__.py +0 -0
  13. pentesty_agent-0.1.0/pentesty_agent/collectors/linux/auth_log.py +65 -0
  14. pentesty_agent-0.1.0/pentesty_agent/collectors/linux/fim.py +72 -0
  15. pentesty_agent-0.1.0/pentesty_agent/collectors/linux/ports.py +76 -0
  16. pentesty_agent-0.1.0/pentesty_agent/collectors/linux/processes.py +64 -0
  17. pentesty_agent-0.1.0/pentesty_agent/collectors/linux/web_logs.py +74 -0
  18. pentesty_agent-0.1.0/pentesty_agent/config.py +42 -0
  19. pentesty_agent-0.1.0/pentesty_agent/main.py +129 -0
  20. pentesty_agent-0.1.0/pentesty_agent/models.py +47 -0
  21. pentesty_agent-0.1.0/pentesty_agent/shipper.py +56 -0
  22. pentesty_agent-0.1.0/pentesty_agent/state.py +43 -0
  23. pentesty_agent-0.1.0/pentesty_agent.egg-info/PKG-INFO +10 -0
  24. pentesty_agent-0.1.0/pentesty_agent.egg-info/SOURCES.txt +29 -0
  25. pentesty_agent-0.1.0/pentesty_agent.egg-info/dependency_links.txt +1 -0
  26. pentesty_agent-0.1.0/pentesty_agent.egg-info/entry_points.txt +2 -0
  27. pentesty_agent-0.1.0/pentesty_agent.egg-info/requires.txt +7 -0
  28. pentesty_agent-0.1.0/pentesty_agent.egg-info/top_level.txt +1 -0
  29. pentesty_agent-0.1.0/pyproject.toml +19 -0
  30. pentesty_agent-0.1.0/setup.cfg +4 -0
  31. 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
@@ -0,0 +1,3 @@
1
+ from pentesty_agent.main import main
2
+
3
+ main()
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from pentesty_agent.models import AgentEvent
6
+
7
+
8
+ class Collector(ABC):
9
+ @abstractmethod
10
+ def collect(self) -> list[AgentEvent]: ...
@@ -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)
@@ -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,2 @@
1
+ [console_scripts]
2
+ pentesty-agent = pentesty_agent.main:main
@@ -0,0 +1,7 @@
1
+ psutil>=6.0.0
2
+ httpx>=0.27.0
3
+ pydantic>=2.0.0
4
+ watchdog>=4.0.0
5
+
6
+ [:python_version < "3.11"]
7
+ tomli>=2.0.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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]