agent-gate-sec 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_gate/__init__.py +3 -0
- agent_gate/__main__.py +5 -0
- agent_gate/cli.py +118 -0
- agent_gate/client/__init__.py +4 -0
- agent_gate/client/cache.py +57 -0
- agent_gate/client/moss_client.py +115 -0
- agent_gate/collector/__init__.py +95 -0
- agent_gate/collector/env_info.py +54 -0
- agent_gate/collector/network_info.py +68 -0
- agent_gate/collector/process_info.py +73 -0
- agent_gate/collector/system_info.py +54 -0
- agent_gate/collector/user_info.py +45 -0
- agent_gate/config.py +252 -0
- agent_gate/normalizer.py +117 -0
- agent_gate/sdk.py +103 -0
- agent_gate/server/__init__.py +30 -0
- agent_gate/server/app.py +137 -0
- agent_gate/server/dashboard.py +468 -0
- agent_gate/server/middleware.py +27 -0
- agent_gate/server/models.py +156 -0
- agent_gate/server/routes.py +266 -0
- agent_gate/storage/__init__.py +4 -0
- agent_gate/storage/db.py +151 -0
- agent_gate/storage/repository.py +261 -0
- agent_gate_sec-0.2.0.dist-info/METADATA +382 -0
- agent_gate_sec-0.2.0.dist-info/RECORD +30 -0
- agent_gate_sec-0.2.0.dist-info/WHEEL +5 -0
- agent_gate_sec-0.2.0.dist-info/entry_points.txt +2 -0
- agent_gate_sec-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_gate_sec-0.2.0.dist-info/top_level.txt +1 -0
agent_gate/__init__.py
ADDED
agent_gate/__main__.py
ADDED
agent_gate/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""CLI entry point for AgentGate service."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import get_config, reload_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="agent-gate",
|
|
13
|
+
description="AgentGate - AI Agent Data Collection & Security Analysis Gateway",
|
|
14
|
+
)
|
|
15
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
16
|
+
|
|
17
|
+
# ---- server command ----
|
|
18
|
+
server_parser = subparsers.add_parser("server", help="Start AgentGate server")
|
|
19
|
+
server_parser.add_argument(
|
|
20
|
+
"--mode", choices=["http", "socket"], default="http",
|
|
21
|
+
help="Server mode: http (TCP) or socket (Unix Domain Socket)"
|
|
22
|
+
)
|
|
23
|
+
server_parser.add_argument(
|
|
24
|
+
"--host", default="0.0.0.0",
|
|
25
|
+
help="Bind host (HTTP mode only, default: 0.0.0.0)"
|
|
26
|
+
)
|
|
27
|
+
server_parser.add_argument(
|
|
28
|
+
"--port", type=int, default=9100,
|
|
29
|
+
help="Bind port (HTTP mode only, default: 9100)"
|
|
30
|
+
)
|
|
31
|
+
server_parser.add_argument(
|
|
32
|
+
"--socket", dest="socket_path",
|
|
33
|
+
help="Unix socket path (socket mode only)"
|
|
34
|
+
)
|
|
35
|
+
server_parser.add_argument(
|
|
36
|
+
"--log-level", choices=["debug", "info", "warning", "error"],
|
|
37
|
+
default="info", help="Log level"
|
|
38
|
+
)
|
|
39
|
+
server_parser.add_argument(
|
|
40
|
+
"--workers", type=int, default=1,
|
|
41
|
+
help="Number of worker processes"
|
|
42
|
+
)
|
|
43
|
+
server_parser.add_argument(
|
|
44
|
+
"--config", type=Path, dest="config_path",
|
|
45
|
+
help="Path to YAML config file"
|
|
46
|
+
)
|
|
47
|
+
server_parser.add_argument(
|
|
48
|
+
"--db-path", type=str, dest="db_path",
|
|
49
|
+
help="SQLite database path override"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ---- clean command ----
|
|
53
|
+
clean_parser = subparsers.add_parser("clean", help="Clean old database records")
|
|
54
|
+
clean_parser.add_argument(
|
|
55
|
+
"--retention-days", type=int, default=30,
|
|
56
|
+
help="Days to retain records (default: 30)"
|
|
57
|
+
)
|
|
58
|
+
clean_parser.add_argument(
|
|
59
|
+
"--config", type=Path, dest="config_path",
|
|
60
|
+
help="Path to YAML config file"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
args = parser.parse_args()
|
|
64
|
+
|
|
65
|
+
if args.command is None:
|
|
66
|
+
parser.print_help()
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
if args.command == "server":
|
|
70
|
+
_run_server(args)
|
|
71
|
+
|
|
72
|
+
elif args.command == "clean":
|
|
73
|
+
_run_clean(args)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_server(args):
|
|
77
|
+
# Load config
|
|
78
|
+
cfg = get_config(args.config_path)
|
|
79
|
+
server_cfg = cfg.server
|
|
80
|
+
|
|
81
|
+
host = args.host or server_cfg.get("host", "0.0.0.0")
|
|
82
|
+
port = args.port or server_cfg.get("port", 9100)
|
|
83
|
+
mode = args.mode or server_cfg.get("mode", "http")
|
|
84
|
+
socket_path = args.socket_path or server_cfg.get("socket_path")
|
|
85
|
+
log_level = args.log_level or server_cfg.get("log_level", "info")
|
|
86
|
+
workers = args.workers or server_cfg.get("workers", 1)
|
|
87
|
+
db_path = args.db_path or cfg.storage.get("db_path") or "data/agent_gate.db"
|
|
88
|
+
|
|
89
|
+
from .server.app import run_server
|
|
90
|
+
run_server(
|
|
91
|
+
host=host,
|
|
92
|
+
port=port,
|
|
93
|
+
mode=mode,
|
|
94
|
+
socket_path=socket_path,
|
|
95
|
+
log_level=log_level,
|
|
96
|
+
workers=workers,
|
|
97
|
+
db_path=db_path,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run_clean(args):
|
|
102
|
+
cfg = get_config(args.config_path)
|
|
103
|
+
db_path = cfg.storage.get("db_path")
|
|
104
|
+
|
|
105
|
+
from .storage.db import get_db
|
|
106
|
+
from .storage.repository import Repository
|
|
107
|
+
from loguru import logger
|
|
108
|
+
|
|
109
|
+
db = get_db(db_path)
|
|
110
|
+
repo = Repository(db)
|
|
111
|
+
|
|
112
|
+
deleted = repo.cleanup_old_records(args.retention_days)
|
|
113
|
+
logger.info(f"Cleaned up {deleted} old records (retention: {args.retention_days} days)")
|
|
114
|
+
db.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""LRU decision cache.
|
|
2
|
+
|
|
3
|
+
Only caches Allow decisions. Deny decisions are never cached.
|
|
4
|
+
Keys are SHA-256 hashes of (session_id, cwd, command).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import time
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DecisionCache:
|
|
14
|
+
"""LRU cache for Allow decisions with TTL-based expiration."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, max_size: int = 500, ttl: int = 300):
|
|
17
|
+
self._max_size = max_size
|
|
18
|
+
self._ttl = ttl # seconds
|
|
19
|
+
self._cache: OrderedDict[str, tuple[float, dict]] = OrderedDict()
|
|
20
|
+
|
|
21
|
+
def _key(self, session_id: str, cwd: str, command: str) -> str:
|
|
22
|
+
raw = f"{session_id}:{cwd}:{command}"
|
|
23
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
def get(self, session_id: str, cwd: str, command: str) -> Optional[dict]:
|
|
26
|
+
key = self._key(session_id, cwd, command)
|
|
27
|
+
entry = self._cache.get(key)
|
|
28
|
+
if entry is None:
|
|
29
|
+
return None
|
|
30
|
+
timestamp, result = entry
|
|
31
|
+
if self._ttl > 0 and (time.time() - timestamp) > self._ttl:
|
|
32
|
+
# Expired
|
|
33
|
+
del self._cache[key]
|
|
34
|
+
return None
|
|
35
|
+
# Move to end (most recently used)
|
|
36
|
+
self._cache.move_to_end(key)
|
|
37
|
+
return result.copy()
|
|
38
|
+
|
|
39
|
+
def set(self, session_id: str, cwd: str, command: str, result: dict) -> None:
|
|
40
|
+
# Only cache Allow decisions
|
|
41
|
+
if result.get("decision") != "Allow":
|
|
42
|
+
return
|
|
43
|
+
key = self._key(session_id, cwd, command)
|
|
44
|
+
if key in self._cache:
|
|
45
|
+
self._cache.move_to_end(key)
|
|
46
|
+
self._cache[key] = (time.time(), result.copy())
|
|
47
|
+
else:
|
|
48
|
+
if len(self._cache) >= self._max_size:
|
|
49
|
+
self._cache.popitem(last=False) # Remove least recently used
|
|
50
|
+
self._cache[key] = (time.time(), result.copy())
|
|
51
|
+
|
|
52
|
+
def clear(self) -> None:
|
|
53
|
+
self._cache.clear()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def size(self) -> int:
|
|
57
|
+
return len(self._cache)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""AgentMoss HTTP/Socket client with Fail-Closed semantics.
|
|
2
|
+
|
|
3
|
+
Makes async HTTP calls to AgentMoss for security analysis.
|
|
4
|
+
If AgentMoss is unreachable, returns Deny (security-first).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from ..config import get_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MossClient:
|
|
16
|
+
"""Async HTTP client for AgentMoss security analysis service.
|
|
17
|
+
|
|
18
|
+
Supports both HTTP TCP and Unix Domain Socket connections.
|
|
19
|
+
Implements Fail-Closed: returns Deny when AgentMoss is unavailable.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: Optional[str] = None,
|
|
25
|
+
timeout: Optional[float] = None,
|
|
26
|
+
max_retries: int = 2,
|
|
27
|
+
):
|
|
28
|
+
cfg = get_config()
|
|
29
|
+
moss_cfg = cfg.agent_moss
|
|
30
|
+
|
|
31
|
+
self._base_url = (base_url or moss_cfg.get("url", "http://127.0.0.1:9090")).rstrip("/")
|
|
32
|
+
self._timeout = timeout or moss_cfg.get("timeout", 30.0)
|
|
33
|
+
self._max_retries = max_retries or moss_cfg.get("max_retries", 2)
|
|
34
|
+
self._retry_delay = moss_cfg.get("retry_delay", 1.0)
|
|
35
|
+
|
|
36
|
+
# Determine transport
|
|
37
|
+
if self._base_url.startswith("unix://"):
|
|
38
|
+
socket_path = self._base_url[len("unix://"):]
|
|
39
|
+
transport = httpx.AsyncHTTPTransport(uds=socket_path)
|
|
40
|
+
self._base_url = "http://localhost" # Placeholder host for UDS
|
|
41
|
+
else:
|
|
42
|
+
transport = None
|
|
43
|
+
|
|
44
|
+
self._client = httpx.AsyncClient(
|
|
45
|
+
transport=transport,
|
|
46
|
+
timeout=httpx.Timeout(self._timeout),
|
|
47
|
+
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def analyze(self, request: dict) -> dict:
|
|
51
|
+
"""Send analysis request to AgentMoss.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
dict with decision/reason/risk_level fields.
|
|
55
|
+
On failure, returns Deny with error details (Fail-Closed).
|
|
56
|
+
"""
|
|
57
|
+
last_error = ""
|
|
58
|
+
for attempt in range(self._max_retries + 1):
|
|
59
|
+
try:
|
|
60
|
+
response = await self._client.post(
|
|
61
|
+
f"{self._base_url}/api/v1/analyze",
|
|
62
|
+
json=request,
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
result = response.json()
|
|
66
|
+
logger.debug(
|
|
67
|
+
f"AgentMoss response: decision={result.get('decision')}, "
|
|
68
|
+
f"duration={result.get('analysis_duration_ms', 0)}ms"
|
|
69
|
+
)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
except httpx.TimeoutException as e:
|
|
73
|
+
last_error = f"AgentMoss timeout after {self._timeout}s"
|
|
74
|
+
logger.warning(f"{last_error} (attempt {attempt + 1})")
|
|
75
|
+
|
|
76
|
+
except httpx.ConnectError as e:
|
|
77
|
+
last_error = f"AgentMoss connection failed: {e}"
|
|
78
|
+
logger.warning(f"{last_error} (attempt {attempt + 1})")
|
|
79
|
+
|
|
80
|
+
except httpx.HTTPStatusError as e:
|
|
81
|
+
last_error = f"AgentMoss HTTP {e.response.status_code}"
|
|
82
|
+
logger.warning(f"{last_error} (attempt {attempt + 1})")
|
|
83
|
+
# Don't retry on 4xx
|
|
84
|
+
if 400 <= e.response.status_code < 500:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
last_error = f"AgentMoss unexpected error: {e}"
|
|
89
|
+
logger.warning(f"{last_error} (attempt {attempt + 1})")
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
# Wait before retry
|
|
93
|
+
if attempt < self._max_retries:
|
|
94
|
+
import asyncio
|
|
95
|
+
await asyncio.sleep(self._retry_delay)
|
|
96
|
+
|
|
97
|
+
# All retries exhausted → Fail-Closed
|
|
98
|
+
logger.error(f"AgentMoss unreachable, returning Deny: {last_error}")
|
|
99
|
+
return self._fail_closed_response(last_error)
|
|
100
|
+
|
|
101
|
+
def _fail_closed_response(self, error: str) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"decision": "Deny",
|
|
104
|
+
"reason": f"AgentMoss security service unavailable — blocking by default: {error}",
|
|
105
|
+
"risk_level": "high",
|
|
106
|
+
"risk_type": "service_unavailable",
|
|
107
|
+
"violated_layers": [],
|
|
108
|
+
"policy": "",
|
|
109
|
+
"violated_policy": "",
|
|
110
|
+
"confidence": 100,
|
|
111
|
+
"analysis_duration_ms": 0,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Collector orchestrator: runs all collectors in parallel via ThreadPoolExecutor."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from ..config import get_config
|
|
9
|
+
from ..server.models import SystemContext, OsInfo, ResourceInfo, ProcessInfo, NetworkInfo, UserInfo
|
|
10
|
+
from .system_info import SystemInfoCollector
|
|
11
|
+
from .process_info import ProcessInfoCollector
|
|
12
|
+
from .env_info import get_env_collector
|
|
13
|
+
from .network_info import NetworkInfoCollector
|
|
14
|
+
from .user_info import UserInfoCollector
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CollectorOrchestrator:
|
|
18
|
+
"""Orchestrates parallel system context collection.
|
|
19
|
+
|
|
20
|
+
Runs all 5 collectors in a thread pool with per-collector timeout.
|
|
21
|
+
Falls back to empty data when a collector fails or times out.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
cfg = get_config().collector
|
|
26
|
+
self._timeout = cfg.get("timeout", 5.0)
|
|
27
|
+
self._pool_size = cfg.get("pool_size", 5)
|
|
28
|
+
self._enabled = {
|
|
29
|
+
"system_info": cfg.get("system_info", True),
|
|
30
|
+
"process_info": cfg.get("process_info", True),
|
|
31
|
+
"env_info": cfg.get("env_info", True),
|
|
32
|
+
"network_info": cfg.get("network_info", True),
|
|
33
|
+
"user_info": cfg.get("user_info", True),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def collect(self) -> SystemContext:
|
|
37
|
+
start = time.monotonic()
|
|
38
|
+
context = SystemContext()
|
|
39
|
+
|
|
40
|
+
collectors = []
|
|
41
|
+
if self._enabled["system_info"]:
|
|
42
|
+
collectors.append(("system_info", SystemInfoCollector.collect))
|
|
43
|
+
if self._enabled["process_info"]:
|
|
44
|
+
collectors.append(("process_info", ProcessInfoCollector.collect))
|
|
45
|
+
if self._enabled["env_info"]:
|
|
46
|
+
collectors.append(("env_info", get_env_collector().collect))
|
|
47
|
+
if self._enabled["network_info"]:
|
|
48
|
+
collectors.append(("network_info", NetworkInfoCollector.collect))
|
|
49
|
+
if self._enabled["user_info"]:
|
|
50
|
+
collectors.append(("user_info", UserInfoCollector.collect))
|
|
51
|
+
|
|
52
|
+
results = {}
|
|
53
|
+
with ThreadPoolExecutor(max_workers=min(len(collectors), self._pool_size)) as executor:
|
|
54
|
+
futures = {
|
|
55
|
+
executor.submit(fn): name
|
|
56
|
+
for name, fn in collectors
|
|
57
|
+
}
|
|
58
|
+
for future in futures:
|
|
59
|
+
name = futures[future]
|
|
60
|
+
try:
|
|
61
|
+
results[name] = future.result(timeout=self._timeout)
|
|
62
|
+
except FutureTimeoutError:
|
|
63
|
+
logger.warning(f"Collector '{name}' timed out after {self._timeout}s")
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.warning(f"Collector '{name}' failed: {e}")
|
|
66
|
+
|
|
67
|
+
# Merge results into context
|
|
68
|
+
for collector_name, data in results.items():
|
|
69
|
+
if "os" in data:
|
|
70
|
+
context.os = OsInfo(**data["os"]) if data["os"] else OsInfo()
|
|
71
|
+
if "resources" in data:
|
|
72
|
+
context.resources = ResourceInfo(**data["resources"]) if data["resources"] else ResourceInfo()
|
|
73
|
+
if "process" in data:
|
|
74
|
+
context.process = ProcessInfo(**data["process"]) if data["process"] else ProcessInfo()
|
|
75
|
+
if "network" in data:
|
|
76
|
+
context.network = NetworkInfo(**data["network"]) if data["network"] else NetworkInfo()
|
|
77
|
+
if "user" in data:
|
|
78
|
+
context.user = UserInfo(**data["user"]) if data["user"] else UserInfo()
|
|
79
|
+
if "env" in data:
|
|
80
|
+
context.env = data["env"]
|
|
81
|
+
|
|
82
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
83
|
+
logger.debug(f"Collection completed in {elapsed:.1f}ms")
|
|
84
|
+
return context
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Singleton
|
|
88
|
+
_orchestrator: CollectorOrchestrator | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_orchestrator() -> CollectorOrchestrator:
|
|
92
|
+
global _orchestrator
|
|
93
|
+
if _orchestrator is None:
|
|
94
|
+
_orchestrator = CollectorOrchestrator()
|
|
95
|
+
return _orchestrator
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Environment variable collector with security filtering.
|
|
2
|
+
|
|
3
|
+
Applies whitelist + blacklist to prevent leaking secrets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from ..config import get_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnvInfoCollector:
|
|
13
|
+
"""Collects environment variables, applying security filters."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
cfg = get_config().security
|
|
17
|
+
self._blacklist: list[re.Pattern] = [
|
|
18
|
+
re.compile(p) for p in cfg.get("env_blacklist", [])
|
|
19
|
+
]
|
|
20
|
+
self._whitelist: list[re.Pattern] = [
|
|
21
|
+
re.compile(p) for p in cfg.get("env_whitelist", [])
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def is_safe(self, key: str) -> bool:
|
|
25
|
+
"""Check if an env var key is safe to collect."""
|
|
26
|
+
# Whitelist takes priority
|
|
27
|
+
for pattern in self._whitelist:
|
|
28
|
+
if pattern.match(key):
|
|
29
|
+
return True
|
|
30
|
+
# Then check blacklist
|
|
31
|
+
for pattern in self._blacklist:
|
|
32
|
+
if pattern.match(key):
|
|
33
|
+
return False
|
|
34
|
+
# Not in either list → don't collect (conservative)
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def collect(self) -> dict:
|
|
38
|
+
result = {}
|
|
39
|
+
for key, value in os.environ.items():
|
|
40
|
+
if self.is_safe(key):
|
|
41
|
+
# Truncate long values (>500 chars)
|
|
42
|
+
result[key] = value[:500] if len(value) > 500 else value
|
|
43
|
+
return {"env": result}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Singleton
|
|
47
|
+
_env_collector: EnvInfoCollector | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_env_collector() -> EnvInfoCollector:
|
|
51
|
+
global _env_collector
|
|
52
|
+
if _env_collector is None:
|
|
53
|
+
_env_collector = EnvInfoCollector()
|
|
54
|
+
return _env_collector
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Network information collector: interfaces, active connections, public IP detection."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NetworkInfoCollector:
|
|
8
|
+
"""Collects network interface and connection summary."""
|
|
9
|
+
|
|
10
|
+
@staticmethod
|
|
11
|
+
def collect() -> dict:
|
|
12
|
+
interfaces = _get_interfaces()
|
|
13
|
+
active_connections = _count_active_connections()
|
|
14
|
+
has_public_ip = _check_public_ip(interfaces)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
"network": {
|
|
18
|
+
"interfaces": interfaces,
|
|
19
|
+
"active_connections": active_connections,
|
|
20
|
+
"has_public_ip": has_public_ip,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_interfaces() -> list[str]:
|
|
26
|
+
"""Get list of non-loopback network interfaces."""
|
|
27
|
+
ifaces = []
|
|
28
|
+
try:
|
|
29
|
+
for name in os.listdir("/sys/class/net/"):
|
|
30
|
+
if name != "lo":
|
|
31
|
+
ifaces.append(name)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
return ifaces
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _count_active_connections() -> int:
|
|
38
|
+
"""Count active network connections."""
|
|
39
|
+
count = 0
|
|
40
|
+
try:
|
|
41
|
+
# TCP connections
|
|
42
|
+
with open("/proc/net/tcp") as f:
|
|
43
|
+
lines = f.readlines()[1:] # Skip header
|
|
44
|
+
count = len(lines)
|
|
45
|
+
with open("/proc/net/tcp6") as f:
|
|
46
|
+
lines = f.readlines()[1:]
|
|
47
|
+
count += len(lines)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return count
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_public_ip(interfaces: list[str]) -> bool:
|
|
54
|
+
"""Check if any interface has a public IP."""
|
|
55
|
+
private_prefixes = ("10.", "172.16.", "172.17.", "172.18.",
|
|
56
|
+
"172.19.", "172.20.", "172.21.", "172.22.",
|
|
57
|
+
"172.23.", "172.24.", "172.25.", "172.26.",
|
|
58
|
+
"172.27.", "172.28.", "172.29.", "172.30.",
|
|
59
|
+
"172.31.", "192.168.", "127.")
|
|
60
|
+
try:
|
|
61
|
+
addrs = socket.getaddrinfo(socket.gethostname(), None)
|
|
62
|
+
for addr in addrs:
|
|
63
|
+
ip = addr[4][0]
|
|
64
|
+
if not any(ip.startswith(p) for p in private_prefixes):
|
|
65
|
+
return True
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return False
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Process information collector: PID, parent process, capabilities, cgroup."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProcessInfoCollector:
|
|
7
|
+
"""Collects information about the current process."""
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def collect() -> dict:
|
|
11
|
+
pid = os.getpid()
|
|
12
|
+
ppid = os.getppid()
|
|
13
|
+
name = ""
|
|
14
|
+
capabilities: list[str] = []
|
|
15
|
+
cgroup = ""
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
with open(f"/proc/{pid}/comm") as f:
|
|
19
|
+
name = f.read().strip()
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with open(f"/proc/{pid}/status") as f:
|
|
25
|
+
for line in f:
|
|
26
|
+
if line.startswith("CapEff:"):
|
|
27
|
+
caps_hex = line.split(":")[1].strip()
|
|
28
|
+
capabilities = _parse_capabilities(caps_hex)
|
|
29
|
+
elif line.startswith("Name:"):
|
|
30
|
+
if not name:
|
|
31
|
+
name = line.split(":")[1].strip()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with open(f"/proc/{pid}/cgroup") as f:
|
|
37
|
+
cgroup = f.readline().strip()
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"process": {
|
|
43
|
+
"pid": pid,
|
|
44
|
+
"ppid": ppid,
|
|
45
|
+
"name": name,
|
|
46
|
+
"capabilities": capabilities,
|
|
47
|
+
"cgroup": cgroup,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_capabilities(caps_hex: str) -> list[str]:
|
|
53
|
+
"""Parse Linux capabilities from hex string."""
|
|
54
|
+
CAP_NAMES = [
|
|
55
|
+
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_FOWNER",
|
|
56
|
+
"CAP_FSETID", "CAP_KILL", "CAP_SETGID", "CAP_SETUID",
|
|
57
|
+
"CAP_SETPCAP", "CAP_LINUX_IMMUTABLE", "CAP_NET_BIND_SERVICE",
|
|
58
|
+
"CAP_NET_BROADCAST", "CAP_NET_ADMIN", "CAP_NET_RAW",
|
|
59
|
+
"CAP_IPC_LOCK", "CAP_IPC_OWNER", "CAP_SYS_MODULE",
|
|
60
|
+
"CAP_SYS_RAWIO", "CAP_SYS_CHROOT", "CAP_SYS_PTRACE",
|
|
61
|
+
"CAP_SYS_PACCT", "CAP_SYS_ADMIN", "CAP_SYS_BOOT",
|
|
62
|
+
"CAP_SYS_NICE", "CAP_SYS_RESOURCE", "CAP_SYS_TIME",
|
|
63
|
+
"CAP_SYS_TTY_CONFIG", "CAP_MKNOD", "CAP_LEASE",
|
|
64
|
+
"CAP_AUDIT_WRITE", "CAP_AUDIT_CONTROL", "CAP_SETFCAP",
|
|
65
|
+
"CAP_MAC_OVERRIDE", "CAP_MAC_ADMIN", "CAP_SYSLOG",
|
|
66
|
+
"CAP_WAKE_ALARM", "CAP_BLOCK_SUSPEND", "CAP_AUDIT_READ",
|
|
67
|
+
"CAP_PERFMON", "CAP_BPF", "CAP_CHECKPOINT_RESTORE",
|
|
68
|
+
]
|
|
69
|
+
try:
|
|
70
|
+
caps_int = int(caps_hex, 16)
|
|
71
|
+
return [name for i, name in enumerate(CAP_NAMES) if caps_int & (1 << i)]
|
|
72
|
+
except (ValueError, IndexError):
|
|
73
|
+
return []
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""System information collector: OS, kernel, CPU, memory, disk."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SystemInfoCollector:
|
|
11
|
+
"""Collects OS-level information."""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def collect() -> dict:
|
|
15
|
+
try:
|
|
16
|
+
return {
|
|
17
|
+
"os": {
|
|
18
|
+
"system": platform.system().lower(),
|
|
19
|
+
"distribution": _get_distribution(),
|
|
20
|
+
"kernel": platform.release(),
|
|
21
|
+
"hostname": platform.node(),
|
|
22
|
+
"machine": platform.machine(),
|
|
23
|
+
},
|
|
24
|
+
"resources": {
|
|
25
|
+
"cpu_count": os.cpu_count() or 0,
|
|
26
|
+
"cpu_percent": round(psutil.cpu_percent(interval=0.1), 1),
|
|
27
|
+
"memory_total_gb": round(psutil.virtual_memory().total / (1024 ** 3), 1),
|
|
28
|
+
"memory_percent": psutil.virtual_memory().percent,
|
|
29
|
+
"disk_total_gb": round(
|
|
30
|
+
psutil.disk_usage("/").total / (1024 ** 3), 1
|
|
31
|
+
),
|
|
32
|
+
"disk_percent": psutil.disk_usage("/").percent,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
except Exception:
|
|
36
|
+
return {"os": {}, "resources": {}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_distribution() -> str:
|
|
40
|
+
"""Detect Linux distribution name."""
|
|
41
|
+
try:
|
|
42
|
+
if os.path.exists("/etc/os-release"):
|
|
43
|
+
with open("/etc/os-release") as f:
|
|
44
|
+
info = {}
|
|
45
|
+
for line in f:
|
|
46
|
+
if "=" in line:
|
|
47
|
+
k, v = line.strip().split("=", 1)
|
|
48
|
+
info[k] = v.strip('"')
|
|
49
|
+
name = info.get("NAME", "")
|
|
50
|
+
version = info.get("VERSION_ID", "")
|
|
51
|
+
return f"{name} {version}".strip()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return platform.platform()
|