ephor 0.3.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.
- ephor/__init__.py +7 -0
- ephor/__main__.py +6 -0
- ephor/classify.py +136 -0
- ephor/cli.py +455 -0
- ephor/collect.py +197 -0
- ephor/config.py +81 -0
- ephor/dnscheck.py +167 -0
- ephor/dnsproxy.py +118 -0
- ephor/feeds.py +185 -0
- ephor/installs.py +228 -0
- ephor/lineage.py +112 -0
- ephor/perms.py +151 -0
- ephor/pkgcheck.py +112 -0
- ephor/probe.py +242 -0
- ephor/query.py +62 -0
- ephor/resolve.py +29 -0
- ephor/serve.py +106 -0
- ephor/store.py +249 -0
- ephor/supply.py +236 -0
- ephor/virustotal.py +171 -0
- ephor/watch.py +344 -0
- ephor-0.3.0.dist-info/METADATA +410 -0
- ephor-0.3.0.dist-info/RECORD +26 -0
- ephor-0.3.0.dist-info/WHEEL +4 -0
- ephor-0.3.0.dist-info/entry_points.txt +2 -0
- ephor-0.3.0.dist-info/licenses/LICENSE +662 -0
ephor/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Ephor — a passive, dependency-light monitor for what AI agents do on a Linux box.
|
|
2
|
+
|
|
3
|
+
No proxy, no agent, no daemon-in-your-traffic: it reads /proc and the socket table, attributes every outbound
|
|
4
|
+
connection to the agent that made it, records where each agent went and how much it pulled down, flags
|
|
5
|
+
known-bad destinations against public threat feeds, and shows what each agent can actually access. Detect-only.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.3.0"
|
ephor/__main__.py
ADDED
ephor/classify.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Attribute a process to an *agent* — generically and configurably.
|
|
2
|
+
|
|
3
|
+
The goal is to turn a raw process (command line, executable, owning user) into a stable agent label like
|
|
4
|
+
``hermes:coder``, ``claude``, ``ollama`` or ``langchain:myscript``. None of this is hard-coded to one estate:
|
|
5
|
+
built-in patterns cover the common AI runtimes, a config file adds your own, and anything unrecognised still
|
|
6
|
+
gets a deterministic ``other:<exe>`` label so nothing slips through unseen.
|
|
7
|
+
|
|
8
|
+
Classification order:
|
|
9
|
+
1. user-supplied patterns from config (``[[agents]] pattern = "...", name = "..."``)
|
|
10
|
+
2. built-in named runtimes (claude, cursor, ollama, the python LLM frameworks, node agents, ...)
|
|
11
|
+
3. python fallback (``python -m pkg`` -> ``py:pkg``; ``python foo.py`` -> ``py:foo``)
|
|
12
|
+
4. last resort (``other:<basename of exe>``)
|
|
13
|
+
|
|
14
|
+
The ignore list drops things that are never an "AI agent doing something" — sshd, systemd, cron, the kernel,
|
|
15
|
+
and (by default) Claude Code itself, so Ephor never reports on the tool you are reading this with.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from . import config
|
|
23
|
+
|
|
24
|
+
# Command lines we never track. Entries are substrings, or /regex/ when wrapped in slashes.
|
|
25
|
+
IGNORE_DEFAULT = [
|
|
26
|
+
r"/(^|/)sshd?\b/",
|
|
27
|
+
r"/\bsystemd(-\w+)?\b/",
|
|
28
|
+
r"/(^|/)(cron|crond|dbus-daemon|rsyslogd|chronyd|agetty|login)\b/",
|
|
29
|
+
r"/\bclaude-code\b/",
|
|
30
|
+
r"@anthropic-ai/claude-code",
|
|
31
|
+
# mesh/VPN and monitoring daemons: real egress, but not AI agents — pure noise. Override via config if wanted.
|
|
32
|
+
r"/\b(tailscaled|netbird|wireguard|zerotier-one)\b/",
|
|
33
|
+
r"/\b(node_exporter|beszel-agent|beszel|prometheus|telegraf|netdata)\b/",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# (compiled cmdline regex, label) — tried in order. {g1} means "use the first capture group, basename-ised".
|
|
37
|
+
_BUILTINS: list[tuple[re.Pattern, str]] = [
|
|
38
|
+
(re.compile(r"\bhermes\b.*?--profile[= ]+(\S+)"), "hermes:{g1}"),
|
|
39
|
+
(re.compile(r"\bhermes\b"), "hermes"),
|
|
40
|
+
(re.compile(r"\bopencode\b"), "opencode"),
|
|
41
|
+
(re.compile(r"\bollama\b"), "ollama"),
|
|
42
|
+
(re.compile(r"@anthropic-ai/claude\b|\bclaude-desktop\b"), "claude"),
|
|
43
|
+
(re.compile(r"\bcursor\b|\bcursor-server\b"), "cursor"),
|
|
44
|
+
(re.compile(r"\bchatgpt\b|\bchatgpt-desktop\b"), "chatgpt-desktop"),
|
|
45
|
+
(re.compile(r"\b(aider)\b"), "aider"),
|
|
46
|
+
(re.compile(r"\b(crawl4ai|playwright|puppeteer)\b"), "scraper"),
|
|
47
|
+
# python LLM frameworks: label by the framework so many scripts collapse to one agent
|
|
48
|
+
(re.compile(r"\b(langchain|langgraph)\b"), "langchain"),
|
|
49
|
+
(re.compile(r"\bcrewai\b"), "crewai"),
|
|
50
|
+
(re.compile(r"\b(autogen|pyautogen|ag2)\b"), "autogen"),
|
|
51
|
+
(re.compile(r"\bllama_index\b|\bllama-index\b"), "llamaindex"),
|
|
52
|
+
(re.compile(r"\bsemantic_kernel\b"), "semantic-kernel"),
|
|
53
|
+
(re.compile(r"\bhaystack\b"), "haystack"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
_PY = re.compile(r"\bpython[0-9.]*\b")
|
|
57
|
+
_PY_M = re.compile(r"-m[= ]+([A-Za-z0-9_.]+)")
|
|
58
|
+
_PY_FILE = re.compile(r"(\S+\.py)\b")
|
|
59
|
+
_NODE = re.compile(r"\bnode\b")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ignore_patterns() -> list:
|
|
63
|
+
out = []
|
|
64
|
+
for raw in IGNORE_DEFAULT + list(config.CONF.get("ignore") or []):
|
|
65
|
+
if len(raw) >= 2 and raw[0] == "/" and raw[-1] == "/":
|
|
66
|
+
try:
|
|
67
|
+
out.append(("re", re.compile(raw[1:-1])))
|
|
68
|
+
except re.error:
|
|
69
|
+
continue
|
|
70
|
+
else:
|
|
71
|
+
out.append(("sub", raw))
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_IGNORE = _ignore_patterns()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _user_patterns() -> list[tuple[re.Pattern, str]]:
|
|
79
|
+
out = []
|
|
80
|
+
for item in config.CONF.get("agents") or []:
|
|
81
|
+
pat, name = item.get("pattern"), item.get("name")
|
|
82
|
+
if pat and name:
|
|
83
|
+
try:
|
|
84
|
+
out.append((re.compile(pat), name))
|
|
85
|
+
except re.error:
|
|
86
|
+
continue
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_USER = _user_patterns()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def ignored(cmd: str) -> bool:
|
|
94
|
+
for kind, pat in _IGNORE:
|
|
95
|
+
if kind == "sub" and pat in cmd:
|
|
96
|
+
return True
|
|
97
|
+
if kind == "re" and pat.search(cmd):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _apply(label: str, m: re.Match) -> str:
|
|
103
|
+
if "{g1}" in label:
|
|
104
|
+
g = (m.group(1) if m.groups() else "") or ""
|
|
105
|
+
g = os.path.basename(g.strip("/")) or "default"
|
|
106
|
+
return label.replace("{g1}", g)
|
|
107
|
+
return label
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def classify(cmd: str, exe: str = "") -> str:
|
|
111
|
+
"""Map a command line (and optional exe path) to an agent label. Never raises."""
|
|
112
|
+
cmd = (cmd or "").strip()
|
|
113
|
+
for pat, label in _USER:
|
|
114
|
+
m = pat.search(cmd)
|
|
115
|
+
if m:
|
|
116
|
+
return _apply(label, m)
|
|
117
|
+
for pat, label in _BUILTINS:
|
|
118
|
+
m = pat.search(cmd)
|
|
119
|
+
if m:
|
|
120
|
+
return _apply(label, m)
|
|
121
|
+
if _PY.search(cmd) or (exe and "python" in os.path.basename(exe)):
|
|
122
|
+
m = _PY_M.search(cmd)
|
|
123
|
+
if m:
|
|
124
|
+
return "py:" + m.group(1).split(".")[0]
|
|
125
|
+
m = _PY_FILE.search(cmd)
|
|
126
|
+
if m:
|
|
127
|
+
return "py:" + os.path.splitext(os.path.basename(m.group(1)))[0]
|
|
128
|
+
return "py:repl"
|
|
129
|
+
if _NODE.search(cmd) or (exe and os.path.basename(exe) in ("node", "nodejs")):
|
|
130
|
+
m = _PY_FILE.search(cmd.replace(".py", ".js")) or re.search(r"(\S+\.(?:js|mjs|cjs|ts))\b", cmd)
|
|
131
|
+
if m:
|
|
132
|
+
return "node:" + os.path.splitext(os.path.basename(m.group(1)))[0]
|
|
133
|
+
return "node"
|
|
134
|
+
base = os.path.basename(exe) if exe else (cmd.split() or ["unknown"])[0]
|
|
135
|
+
base = os.path.basename(base.strip("/")) or "unknown"
|
|
136
|
+
return "other:" + base
|
ephor/cli.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Command line. Detect-only: Ephor reports, it never blocks, kills or firewalls anything.
|
|
2
|
+
|
|
3
|
+
ephor watch [--resolve] [--no-feeds] take one sample, attribute it, fold it into history
|
|
4
|
+
ephor watch --loop [--interval N] re-sample on an interval, alerting on new/dangerous activity
|
|
5
|
+
ephor agents every agent seen, with destination count and bytes pulled
|
|
6
|
+
ephor agent <name> [--perms] one agent's destinations (and, with --perms, its blast radius)
|
|
7
|
+
ephor history [--agent A] [--bad] the destination log, newest first
|
|
8
|
+
ephor status database + feed summary
|
|
9
|
+
ephor feeds [--refresh] threat-feed cache state
|
|
10
|
+
ephor serve [--host] [--port] read-only HTML view (stdlib only; LAN use)
|
|
11
|
+
|
|
12
|
+
Add --json to any read command for machine-readable output. `watch` exits non-zero if a connection to a
|
|
13
|
+
known-bad destination was seen in that sample, so it drops straight into cron or a CI step.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from . import __version__, collect, config, feeds, perms, resolve, store, virustotal
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_BAD = 2 # a known-bad destination was observed
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _age(ts: int) -> str:
|
|
30
|
+
if not ts:
|
|
31
|
+
return "never"
|
|
32
|
+
d = int(time.time()) - ts
|
|
33
|
+
for unit, n in (("d", 86400), ("h", 3600), ("m", 60)):
|
|
34
|
+
if d >= n:
|
|
35
|
+
return f"{d // n}{unit} ago"
|
|
36
|
+
return f"{d}s ago"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fmt_bytes(n: int) -> str:
|
|
40
|
+
n = float(n or 0)
|
|
41
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
42
|
+
if n < 1024:
|
|
43
|
+
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
44
|
+
n /= 1024
|
|
45
|
+
return f"{n:.1f}PB"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_watch(args) -> int:
|
|
49
|
+
if args.loop:
|
|
50
|
+
from . import watch
|
|
51
|
+
return watch.run_loop(args)
|
|
52
|
+
s = collect.sample()
|
|
53
|
+
if not s.have_ss:
|
|
54
|
+
print("ephor: 'ss' (iproute2) not found — cannot read the socket table", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
if not s.is_root:
|
|
57
|
+
print("ephor: not running as root — only connections owned by this user are attributable",
|
|
58
|
+
file=sys.stderr)
|
|
59
|
+
conns = collect.attribute(s)
|
|
60
|
+
matcher = None if args.no_feeds else feeds.Matcher()
|
|
61
|
+
vt = None
|
|
62
|
+
if virustotal.enabled(getattr(args, "virustotal", False)):
|
|
63
|
+
vt = virustotal.VirusTotal()
|
|
64
|
+
print(virustotal.PRIVACY_WARNING, file=sys.stderr)
|
|
65
|
+
vt_budget = 3 # at most a few VT lookups per run, so a busy box never blows the free-tier quota
|
|
66
|
+
db = store.connect()
|
|
67
|
+
bad_seen = False
|
|
68
|
+
agents_seen = set()
|
|
69
|
+
with db:
|
|
70
|
+
for c in conns:
|
|
71
|
+
agents_seen.add(c.agent)
|
|
72
|
+
dest = resolve.ptr(c.rip) if args.resolve else c.rip
|
|
73
|
+
is_bad, indicator, feed = (False, "", "")
|
|
74
|
+
if matcher:
|
|
75
|
+
is_bad, indicator, feed = matcher.check(ip=c.rip, domain=dest if args.resolve else "")
|
|
76
|
+
# VirusTotal only enriches destinations the free feeds didn't already flag, and only a few per run.
|
|
77
|
+
if vt and not is_bad and vt_budget > 0:
|
|
78
|
+
vt_budget -= 1
|
|
79
|
+
res = vt.check_destination(ip=c.rip, domain=dest if args.resolve else "")
|
|
80
|
+
if res and (res["malicious"] > 0 or res["suspicious"] > 1):
|
|
81
|
+
is_bad, indicator, feed = True, dest, "virustotal"
|
|
82
|
+
store.record_conn(db, c.agent, dest, c.rip, c.rport, s.ts, c.bytes_recv, is_bad, feed)
|
|
83
|
+
if is_bad:
|
|
84
|
+
bad_seen = True
|
|
85
|
+
store.record_alert(db, c.agent, dest, c.rip, feed, indicator, s.ts)
|
|
86
|
+
for a in agents_seen:
|
|
87
|
+
store.record_agent(db, a, s.ts)
|
|
88
|
+
if args.json:
|
|
89
|
+
print(json.dumps({
|
|
90
|
+
"ts": s.ts, "host": s.host, "root": s.is_root,
|
|
91
|
+
"connections": len(conns), "agents": sorted(agents_seen), "bad_seen": bad_seen,
|
|
92
|
+
}))
|
|
93
|
+
else:
|
|
94
|
+
print(f"ephor: {len(conns)} agent connection(s), {len(agents_seen)} agent(s)"
|
|
95
|
+
+ (" — \033[31mKNOWN-BAD destination seen\033[0m" if bad_seen else ""))
|
|
96
|
+
db.close()
|
|
97
|
+
return EXIT_BAD if bad_seen else EXIT_OK
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_agents(args) -> int:
|
|
101
|
+
db = store.connect()
|
|
102
|
+
rows = store.agents(db)
|
|
103
|
+
db.close()
|
|
104
|
+
if args.json:
|
|
105
|
+
print(json.dumps(rows, indent=2))
|
|
106
|
+
return EXIT_OK
|
|
107
|
+
if not rows:
|
|
108
|
+
print("no agents recorded yet — run 'ephor watch' (ideally on a timer)")
|
|
109
|
+
return EXIT_OK
|
|
110
|
+
print(f"{'AGENT':<24} {'DESTS':>5} {'DOWNLOADED':>10} {'BAD':>3} {'LAST SEEN':<10}")
|
|
111
|
+
for r in rows:
|
|
112
|
+
flag = f"\033[31m{r['bad_dests']}\033[0m" if r["bad_dests"] else "0"
|
|
113
|
+
print(f"{r['agent']:<24} {r['dests']:>5} {_fmt_bytes(r['bytes_recv']):>10} {flag:>3} "
|
|
114
|
+
f"{_age(r['last_seen']):<10}")
|
|
115
|
+
return EXIT_OK
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_agent(args) -> int:
|
|
119
|
+
db = store.connect()
|
|
120
|
+
rows = store.agent_conns(db, args.name)
|
|
121
|
+
if args.perms:
|
|
122
|
+
s = collect.sample()
|
|
123
|
+
snap = None
|
|
124
|
+
for c in collect.attribute(s):
|
|
125
|
+
if c.agent == args.name:
|
|
126
|
+
snap = perms.snapshot(c.pid, c.user, config.secret_paths())
|
|
127
|
+
break
|
|
128
|
+
db.close()
|
|
129
|
+
if args.json:
|
|
130
|
+
out = {"agent": args.name, "destinations": rows}
|
|
131
|
+
if args.perms:
|
|
132
|
+
out["permissions"] = snap
|
|
133
|
+
print(json.dumps(out, indent=2))
|
|
134
|
+
return EXIT_OK
|
|
135
|
+
if not rows:
|
|
136
|
+
print(f"no destinations recorded for agent '{args.name}'")
|
|
137
|
+
else:
|
|
138
|
+
print(f"== {args.name}: {len(rows)} destination(s) ==")
|
|
139
|
+
print(f"{'DESTINATION':<40} {'PORT':>5} {'DOWNLOADED':>10} {'SEEN':>5} FLAG")
|
|
140
|
+
for r in rows:
|
|
141
|
+
flag = f"\033[31mBAD:{r['feed']}\033[0m" if r["bad"] else ""
|
|
142
|
+
print(f"{r['dest']:<40} {r['port']:>5} {_fmt_bytes(r['bytes_recv']):>10} {r['times_seen']:>5} {flag}")
|
|
143
|
+
if args.perms:
|
|
144
|
+
if not snap:
|
|
145
|
+
print("\npermissions: agent not currently running (snapshot needs a live process)")
|
|
146
|
+
else:
|
|
147
|
+
idn = snap["identity"]
|
|
148
|
+
print(f"\n== {args.name}: permissions (blast radius) ==")
|
|
149
|
+
print(f" runs as : {idn['user']} (uid {idn['uid']}) groups: {', '.join(idn['groups']) or '-'}")
|
|
150
|
+
print(f" sudo nopass : {snap['sudo']}")
|
|
151
|
+
caps = snap["caps"] or ["(none)"]
|
|
152
|
+
hi = set(snap["high_risk_caps"])
|
|
153
|
+
print(" capabilities: " + ", ".join(
|
|
154
|
+
(f"\033[31m{c}\033[0m" if c in hi else c) for c in caps))
|
|
155
|
+
secs = snap["readable_secrets"]
|
|
156
|
+
print(f" can read : {', '.join(secs) if secs else '(no configured secret paths readable)'}")
|
|
157
|
+
return EXIT_OK
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_history(args) -> int:
|
|
161
|
+
db = store.connect()
|
|
162
|
+
rows = store.history(db, agent=args.agent, bad_only=args.bad)
|
|
163
|
+
db.close()
|
|
164
|
+
if args.json:
|
|
165
|
+
print(json.dumps(rows, indent=2))
|
|
166
|
+
return EXIT_OK
|
|
167
|
+
if not rows:
|
|
168
|
+
print("no history")
|
|
169
|
+
return EXIT_OK
|
|
170
|
+
print(f"{'AGENT':<20} {'DESTINATION':<34} {'PORT':>5} {'DOWNLOADED':>10} {'LAST':<9} FLAG")
|
|
171
|
+
for r in rows:
|
|
172
|
+
flag = f"\033[31mBAD:{r['feed']}\033[0m" if r["bad"] else ""
|
|
173
|
+
print(f"{r['agent']:<20} {r['dest']:<34} {r['port']:>5} {_fmt_bytes(r['bytes_recv']):>10} "
|
|
174
|
+
f"{_age(r['last_seen']):<9} {flag}")
|
|
175
|
+
return EXIT_OK
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_status(args) -> int:
|
|
179
|
+
db = store.connect()
|
|
180
|
+
st = store.stats(db)
|
|
181
|
+
db.close()
|
|
182
|
+
m = feeds.Matcher()
|
|
183
|
+
st["feed_indicators"] = m.total()
|
|
184
|
+
st["version"] = __version__
|
|
185
|
+
if args.json:
|
|
186
|
+
print(json.dumps(st, indent=2))
|
|
187
|
+
return EXIT_OK
|
|
188
|
+
print(f"ephor {__version__}")
|
|
189
|
+
print(f" agents tracked : {st['agents']}")
|
|
190
|
+
print(f" destinations : {st['destinations']} ({st['bad_destinations']} known-bad)")
|
|
191
|
+
print(f" alerts : {st['alerts']}")
|
|
192
|
+
print(f" feed indicators : {st['feed_indicators']}")
|
|
193
|
+
vt_on = virustotal.enabled()
|
|
194
|
+
budget = virustotal.VirusTotal().budget if vt_on else None
|
|
195
|
+
print(f" virustotal : {'on' if vt_on else 'off'}"
|
|
196
|
+
+ (f" ({budget['left']}/{budget['cap']} left today)" if budget else ""))
|
|
197
|
+
print(f" last sample : {_age(st['last_sample'])}")
|
|
198
|
+
print(f" database : {config.db_path()}")
|
|
199
|
+
return EXIT_OK
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cmd_feeds(args) -> int:
|
|
203
|
+
if args.refresh:
|
|
204
|
+
status = feeds.refresh(force=True)
|
|
205
|
+
else:
|
|
206
|
+
status = feeds.refresh(force=False)
|
|
207
|
+
m = feeds.Matcher()
|
|
208
|
+
if args.json:
|
|
209
|
+
print(json.dumps({"feeds": status, "indicators_loaded": m.total()}, indent=2))
|
|
210
|
+
return EXIT_OK
|
|
211
|
+
for name, (state, count) in status.items():
|
|
212
|
+
print(f" {name:<12} {state:<12} {count} line(s)")
|
|
213
|
+
print(f" loaded indicators: {m.total()}")
|
|
214
|
+
return EXIT_OK
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_query(args) -> int:
|
|
218
|
+
import sqlite3
|
|
219
|
+
|
|
220
|
+
from . import query
|
|
221
|
+
if args.schema:
|
|
222
|
+
print(query.schema())
|
|
223
|
+
return EXIT_OK
|
|
224
|
+
if not args.sql:
|
|
225
|
+
print("ephor: give a SELECT, or --schema to see the tables", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
try:
|
|
228
|
+
cols, rows = query.run(args.sql, limit=args.limit)
|
|
229
|
+
except sqlite3.Error as e:
|
|
230
|
+
print(f"ephor: {e}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
if args.json:
|
|
233
|
+
print(json.dumps([dict(zip(cols, r, strict=True)) for r in rows], indent=1, default=str))
|
|
234
|
+
elif cols:
|
|
235
|
+
print(query.table(cols, rows))
|
|
236
|
+
return EXIT_OK
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_installs(args) -> int:
|
|
240
|
+
db = store.connect()
|
|
241
|
+
cmds, pkgs = store.installs(db, args.limit), store.new_packages(db, args.limit)
|
|
242
|
+
db.close()
|
|
243
|
+
if args.json:
|
|
244
|
+
print(json.dumps({"commands": cmds, "new_packages": pkgs}, indent=1))
|
|
245
|
+
return EXIT_OK
|
|
246
|
+
print(f"install commands ({len(cmds)}):")
|
|
247
|
+
for r in cmds:
|
|
248
|
+
flag = f" \033[31m{r['flags']}\033[0m" if r["flags"] else ""
|
|
249
|
+
via = f" via {r['via']}" if r["via"] else ""
|
|
250
|
+
print(f" {_age(r['ts']):<10} {r['agent']}{via:<14} {r['manager']:<8} {r['packages'][:60]}{flag}")
|
|
251
|
+
print(f"\nnew packages since the first snapshot ({len(pkgs)}):")
|
|
252
|
+
for r in pkgs:
|
|
253
|
+
flag = f" \033[31m{r['flags']}\033[0m" if r["flags"] else ""
|
|
254
|
+
print(f" {_age(r['first_seen']):<10} {r['root']:<10} {r['manager']}:{r['name']} {r['version']}"
|
|
255
|
+
f" [{r['agents'] or 'no agent seen'}]{flag}")
|
|
256
|
+
return EXIT_OK
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_dns(args) -> int:
|
|
260
|
+
db = store.connect()
|
|
261
|
+
rows = store.dns_names(db, agent=args.agent, flagged=args.flagged, limit=args.limit)
|
|
262
|
+
db.close()
|
|
263
|
+
if args.json:
|
|
264
|
+
print(json.dumps(rows, indent=1))
|
|
265
|
+
return EXIT_OK
|
|
266
|
+
for r in rows:
|
|
267
|
+
flag = f" \033[31m{r['flags']}\033[0m" if r["flags"] else ""
|
|
268
|
+
print(f"{_age(r['last_seen']):<10} {r['agent']:<20} {r['times_seen']:>5} {r['name']}{flag}")
|
|
269
|
+
return EXIT_OK
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_probe(args) -> int:
|
|
273
|
+
from . import probe
|
|
274
|
+
if args.print:
|
|
275
|
+
print(probe.program(probe.libcs()))
|
|
276
|
+
return EXIT_OK
|
|
277
|
+
why = probe.check()
|
|
278
|
+
if args.check or why:
|
|
279
|
+
if why:
|
|
280
|
+
print("ephor: the eBPF probe can't run here:\n - " + "\n - ".join(why), file=sys.stderr)
|
|
281
|
+
return 1
|
|
282
|
+
libs = probe.libcs()
|
|
283
|
+
light = "" if probe.native() else " (install ephor-probe for ~10 MB instead of ~110 MB)"
|
|
284
|
+
print(f"ephor: eBPF probe ready: backend {probe.backend()}{light}, root, BTF; "
|
|
285
|
+
f"DNS hooks on {len(libs)} libc file(s)")
|
|
286
|
+
return EXIT_OK
|
|
287
|
+
from . import lineage
|
|
288
|
+
st = probe.Stream()
|
|
289
|
+
res = lineage.Resolver()
|
|
290
|
+
print("ephor: probe running; Ctrl-C to stop", file=sys.stderr)
|
|
291
|
+
try:
|
|
292
|
+
while st.alive():
|
|
293
|
+
for ev in st.drain():
|
|
294
|
+
if ev.kind == "exec":
|
|
295
|
+
res.note(ev.pid, ev.ppid, ev.cmd)
|
|
296
|
+
agent, via = res.resolve(ev.pid, ev.cmd if ev.kind == "exec" else "")
|
|
297
|
+
what = ev.cmd if ev.kind == "exec" else ev.name if ev.kind == "dns" else f"{ev.ip}:{ev.port}"
|
|
298
|
+
print(f"{ev.kind:<7} {ev.pid:>7} {agent}{' via ' + via if via else ''} {what[:160]}", flush=True)
|
|
299
|
+
time.sleep(0.5)
|
|
300
|
+
print(f"ephor: probe stopped: {st.error()}", file=sys.stderr)
|
|
301
|
+
return 1
|
|
302
|
+
except KeyboardInterrupt:
|
|
303
|
+
return EXIT_OK
|
|
304
|
+
finally:
|
|
305
|
+
st.stop()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_dnsproxy(args) -> int:
|
|
309
|
+
from . import dnsproxy, lineage, supply, watch
|
|
310
|
+
matcher = None if args.no_feeds else feeds.Matcher()
|
|
311
|
+
sp = supply.Supply(ebpf=False, inventory_every=0, pkg_checks=False, use_sieve=False, rdap=args.rdap,
|
|
312
|
+
matcher=matcher)
|
|
313
|
+
res = lineage.Resolver()
|
|
314
|
+
sinks = watch.make_sinks(args.webhook or "", args.syslog)
|
|
315
|
+
upstream = args.upstream or dnsproxy.upstream_from_resolv()
|
|
316
|
+
host = os.uname().nodename
|
|
317
|
+
|
|
318
|
+
def on_query(pid: int, name: str) -> None:
|
|
319
|
+
agent, via = res.resolve(pid) if pid else ("unknown", "")
|
|
320
|
+
db = store.connect()
|
|
321
|
+
now = int(time.time())
|
|
322
|
+
|
|
323
|
+
def emit(reason, *, agent, via="", container="", dest="", detail="", **_):
|
|
324
|
+
store.record_alert(db, agent, dest, "", reason, detail[:200], now)
|
|
325
|
+
a = watch.Alert(ts=now, host=host, container=container, agent=agent, client="", crawler=False,
|
|
326
|
+
dest=dest, ip="", port=0, bytes_recv=0, reason=reason, via=via, detail=detail)
|
|
327
|
+
for s in sinks:
|
|
328
|
+
s.send(a)
|
|
329
|
+
with db:
|
|
330
|
+
sp.note_dns(db, now, agent, via, "", name, emit)
|
|
331
|
+
db.close()
|
|
332
|
+
print(f"ephor: dns-proxy on {args.listen} -> {upstream}; point the resolver at it. Ctrl-C to stop",
|
|
333
|
+
file=sys.stderr)
|
|
334
|
+
try:
|
|
335
|
+
dnsproxy.serve(args.listen, upstream, on_query)
|
|
336
|
+
except KeyboardInterrupt:
|
|
337
|
+
return EXIT_OK
|
|
338
|
+
except OSError as e:
|
|
339
|
+
print(f"ephor: dns-proxy: {e}", file=sys.stderr)
|
|
340
|
+
return 1
|
|
341
|
+
return EXIT_OK
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_serve(args) -> int:
|
|
345
|
+
from . import serve as _serve
|
|
346
|
+
return _serve.run(args.host, args.port)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
350
|
+
p = argparse.ArgumentParser(prog="ephor", description="Passive, detect-only monitor of AI-agent network activity.")
|
|
351
|
+
p.add_argument("--version", action="version", version=f"ephor {__version__}")
|
|
352
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
353
|
+
|
|
354
|
+
w = sub.add_parser("watch", help="sample current connections and record them")
|
|
355
|
+
w.add_argument("--resolve", action="store_true", help="reverse-DNS destinations (best-effort)")
|
|
356
|
+
w.add_argument("--no-feeds", action="store_true", help="skip threat-feed matching this run")
|
|
357
|
+
w.add_argument("--virustotal", action="store_true",
|
|
358
|
+
help="also enrich via VirusTotal (sends destinations to a third party; needs a key)")
|
|
359
|
+
w.add_argument("--loop", action="store_true",
|
|
360
|
+
help="run continuously, re-sampling and alerting on new/dangerous activity")
|
|
361
|
+
w.add_argument("--interval", type=int, default=30, metavar="N",
|
|
362
|
+
help="seconds between samples in --loop mode (default 30)")
|
|
363
|
+
w.add_argument("--webhook", help="POST alerts to a Discord/Slack/generic JSON webhook URL")
|
|
364
|
+
w.add_argument("--syslog", action="store_true", help="also emit alerts to syslog")
|
|
365
|
+
w.add_argument("--json", action="store_true")
|
|
366
|
+
w.add_argument("--ebpf", action="store_true",
|
|
367
|
+
help="with --loop: also read the eBPF probe (every command, DNS lookup and connection as it "
|
|
368
|
+
"happens; needs root and bpftrace, run on the host)")
|
|
369
|
+
w.add_argument("--inventory-every", type=int, default=300, metavar="N",
|
|
370
|
+
help="with --loop: snapshot installed packages every N seconds and alert on new ones "
|
|
371
|
+
"(default 300, 0 = off)")
|
|
372
|
+
w.add_argument("--inventory-root", action="append", metavar="PATH",
|
|
373
|
+
help="with --loop: snapshot packages under this root too (repeatable; default /, plus each "
|
|
374
|
+
"container a named agent acts in, when --ebpf is on)")
|
|
375
|
+
w.add_argument("--no-pkg-checks", action="store_true",
|
|
376
|
+
help="don't look new packages up on OSV.dev (malicious) or PyPI/npm (first published)")
|
|
377
|
+
w.add_argument("--sieve", action="store_true",
|
|
378
|
+
help="run `sieve path` where new Python/npm packages land (needs sieve installed)")
|
|
379
|
+
w.add_argument("--rdap", action="store_true",
|
|
380
|
+
help="look up when each new domain an agent resolves was registered (network, RDAP)")
|
|
381
|
+
w.set_defaults(func=cmd_watch)
|
|
382
|
+
|
|
383
|
+
a = sub.add_parser("agents", help="list all agents seen")
|
|
384
|
+
a.add_argument("--json", action="store_true")
|
|
385
|
+
a.set_defaults(func=cmd_agents)
|
|
386
|
+
|
|
387
|
+
g = sub.add_parser("agent", help="show one agent's destinations (and --perms)")
|
|
388
|
+
g.add_argument("name")
|
|
389
|
+
g.add_argument("--perms", action="store_true", help="also show what the agent can access, if live")
|
|
390
|
+
g.add_argument("--json", action="store_true")
|
|
391
|
+
g.set_defaults(func=cmd_agent)
|
|
392
|
+
|
|
393
|
+
h = sub.add_parser("history", help="destination log")
|
|
394
|
+
h.add_argument("--agent", help="filter to one agent")
|
|
395
|
+
h.add_argument("--bad", action="store_true", help="only known-bad destinations")
|
|
396
|
+
h.add_argument("--json", action="store_true")
|
|
397
|
+
h.set_defaults(func=cmd_history)
|
|
398
|
+
|
|
399
|
+
s = sub.add_parser("status", help="database and feed summary")
|
|
400
|
+
s.add_argument("--json", action="store_true")
|
|
401
|
+
s.set_defaults(func=cmd_status)
|
|
402
|
+
|
|
403
|
+
f = sub.add_parser("feeds", help="threat-feed cache state")
|
|
404
|
+
f.add_argument("--refresh", action="store_true", help="force a refetch now")
|
|
405
|
+
f.add_argument("--json", action="store_true")
|
|
406
|
+
f.set_defaults(func=cmd_feeds)
|
|
407
|
+
|
|
408
|
+
q = sub.add_parser("query", help="read-only SQL over the history (osquery-style)")
|
|
409
|
+
q.add_argument("sql", nargs="?", help='e.g. "SELECT agent, name FROM dns WHERE flags != \'\'"')
|
|
410
|
+
q.add_argument("--schema", action="store_true", help="print the tables")
|
|
411
|
+
q.add_argument("--limit", type=int, default=1000, metavar="N")
|
|
412
|
+
q.add_argument("--json", action="store_true")
|
|
413
|
+
q.set_defaults(func=cmd_query)
|
|
414
|
+
|
|
415
|
+
i = sub.add_parser("installs", help="install commands agents ran, and packages that appeared")
|
|
416
|
+
i.add_argument("--limit", type=int, default=50, metavar="N")
|
|
417
|
+
i.add_argument("--json", action="store_true")
|
|
418
|
+
i.set_defaults(func=cmd_installs)
|
|
419
|
+
|
|
420
|
+
d = sub.add_parser("dns", help="names agents looked up (from --ebpf or dns-proxy)")
|
|
421
|
+
d.add_argument("--agent", help="filter to one agent")
|
|
422
|
+
d.add_argument("--flagged", action="store_true", help="only names a check flagged")
|
|
423
|
+
d.add_argument("--limit", type=int, default=200, metavar="N")
|
|
424
|
+
d.add_argument("--json", action="store_true")
|
|
425
|
+
d.set_defaults(func=cmd_dns)
|
|
426
|
+
|
|
427
|
+
pr = sub.add_parser("probe", help="the eBPF probe on its own: --check, --print, or stream events")
|
|
428
|
+
pr.add_argument("--check", action="store_true", help="can the probe run here?")
|
|
429
|
+
pr.add_argument("--print", action="store_true", help="print the bpftrace program it would run")
|
|
430
|
+
pr.set_defaults(func=cmd_probe)
|
|
431
|
+
|
|
432
|
+
dp = sub.add_parser("dns-proxy", help="opt-in local DNS forwarder that ties each name to the process "
|
|
433
|
+
"(for boxes without eBPF)")
|
|
434
|
+
dp.add_argument("--listen", default="127.0.0.1:5353", metavar="ADDR:PORT")
|
|
435
|
+
dp.add_argument("--upstream", metavar="IP", help="real resolver (default: first nameserver in resolv.conf)")
|
|
436
|
+
dp.add_argument("--no-feeds", action="store_true")
|
|
437
|
+
dp.add_argument("--rdap", action="store_true", help="also check how recently each domain was registered")
|
|
438
|
+
dp.add_argument("--webhook", help="POST alerts to a Discord/Slack/generic JSON webhook URL")
|
|
439
|
+
dp.add_argument("--syslog", action="store_true")
|
|
440
|
+
dp.set_defaults(func=cmd_dnsproxy)
|
|
441
|
+
|
|
442
|
+
sv = sub.add_parser("serve", help="read-only HTML view (stdlib; bind to LAN only)")
|
|
443
|
+
sv.add_argument("--host", default="127.0.0.1")
|
|
444
|
+
sv.add_argument("--port", type=int, default=8799)
|
|
445
|
+
sv.set_defaults(func=cmd_serve)
|
|
446
|
+
return p
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main(argv=None) -> int:
|
|
450
|
+
args = build_parser().parse_args(argv)
|
|
451
|
+
return args.func(args)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
sys.exit(main())
|