daemonaudit 0.1.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.
@@ -0,0 +1,3 @@
1
+ """daemonaudit — red/blue security audit for self-hosted AI agent daemons."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from daemonaudit.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
daemonaudit/banner.py ADDED
@@ -0,0 +1,13 @@
1
+ from daemonaudit import __version__
2
+
3
+ DEMON = r"""
4
+ ^ ^
5
+ |\ /|
6
+ ( o o ) daemonaudit v{v}
7
+ \ ~~ / who can hurt your agent — and how badly
8
+ `----´
9
+ """
10
+
11
+
12
+ def banner() -> str:
13
+ return DEMON.format(v=__version__)
@@ -0,0 +1,3 @@
1
+ from daemonaudit.chain.rules import build_attack_paths, build_blast_radius
2
+
3
+ __all__ = ["build_attack_paths", "build_blast_radius"]
@@ -0,0 +1,161 @@
1
+ """Chain rules: turn a flat list of findings into attack paths.
2
+
3
+ A rule is an ordered list of hops; each hop is a set of tags, any one of which
4
+ satisfies it. A path exists when every hop has at least one finding carrying one of
5
+ its tags. The first matching finding per hop becomes the hop shown in the report.
6
+ `kill_hop` is the earliest hop — fix it and the whole path is gone.
7
+
8
+ A path needs a real foothold: hop 1 must be MEDIUM or worse, intermediate hops at
9
+ least LOW; only the final hop (what is reached) may be INFO. Without this, the vendor's
10
+ own `curl | bash` install docs (LOW, bundled) would "chain" on every default install.
11
+
12
+ Tags are set by checks (see each check) — rules never parse titles.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from daemonaudit.model import AttackPath, BlastEntry, Finding, RedactedSecret, ScanReport, Severity
20
+
21
+ # What a stolen credential of each kind lets an attacker do.
22
+ BLAST: dict[str, str] = {
23
+ "anthropic-api-key": "spend on your Anthropic account and run models as you; reads nothing of yours",
24
+ "openai-api-key": "spend on your OpenAI account; may reach fine-tunes, files and assistants you created",
25
+ "openrouter-api-key": "spend your OpenRouter balance across every provider it fronts",
26
+ "azure-openai-api-key": "spend on your Azure OpenAI resource",
27
+ "google-api-key": "billable calls on every Google API the key is enabled for",
28
+ "google-oauth-client-secret": "impersonate your OAuth app to users who trust it",
29
+ "github-token": "act as you on GitHub: read private repos, push commits, open PRs, read Actions secrets it can see",
30
+ "github-fine-grained-pat": "whatever repos/permissions the PAT was scoped to, as you",
31
+ "telegram-bot-token": "become your bot: read every message sent to it and reply to your contacts as it",
32
+ "discord-bot-token": "become your bot in every server it is in: read channels it can see, post as it",
33
+ "discord-webhook-url": "post anything into that Discord channel as the webhook",
34
+ "slack-token": "read channels and DMs the token can see; post as the bot or as you",
35
+ "slack-app-token": "open Socket Mode connections as your Slack app",
36
+ "aws-access-key-id": "whatever the IAM identity allows — often everything in the account",
37
+ "private-key-block": "log in to every host or service that trusts this key",
38
+ "jwt": "act as the session/identity the token represents until it expires",
39
+ "bearer-token": "act as whatever identity the bearer token represents",
40
+ "url-embedded-credential": "log in to that service as that user",
41
+ "generic-credential": "unknown — depends on the service; assume the worst until you know",
42
+ }
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Rule:
47
+ name: str
48
+ narrative: str
49
+ hops: tuple[frozenset[str], ...]
50
+ reaches_default: str
51
+
52
+
53
+ RULES: list[Rule] = [
54
+ Rule(
55
+ "Remote → agent tools → every credential",
56
+ "Something on the network reaches the daemon without a password, hands it a prompt, and the agent runs commands on your host with your keys in its environment.",
57
+ (frozenset({"net:public"}), frozenset({"net:unauth", "net:unauth:verified"}), frozenset({"exec:host", "exec:noapproval"}), frozenset({"secret:procenv", "secret:vault"})),
58
+ "every credential the daemon holds",
59
+ ),
60
+ Rule(
61
+ "Local unauthenticated service → agent tools → every credential",
62
+ "A service on this host answers without auth. Anything running as any user here — a browser tab via a crafted page, another account, a compromised package — can drive the agent.",
63
+ (frozenset({"net:unauth", "net:unauth:verified"}), frozenset({"exec:host", "exec:noapproval"}), frozenset({"secret:procenv", "secret:vault"})),
64
+ "every credential the daemon holds",
65
+ ),
66
+ Rule(
67
+ "Anyone on the chat platform → prompt injection → commands as you",
68
+ "The front door is open to strangers. A message is a prompt; with approvals weakened, a prompt is a shell.",
69
+ (frozenset({"content:allow-all", "net:unauth-inbound"}), frozenset({"exec:noapproval", "exec:noapproval:cron"}), frozenset({"exec:host", "secret:procenv", "secret:vault"})),
70
+ "command execution as your user, and every credential",
71
+ ),
72
+ Rule(
73
+ "Malicious skill → remote code → your host",
74
+ "A skill you installed downloads and runs code on first use. It runs where the agent runs: on your host, as you.",
75
+ (frozenset({"skill:remote-exec"}), frozenset({"exec:host"}), frozenset({"secret:procenv", "secret:vault"})),
76
+ "everything in the daemon's environment",
77
+ ),
78
+ Rule(
79
+ "Skill sees master keys → exfiltration",
80
+ "Provider keys are passed into every command the agent runs, and at least one skill both reads credentials and talks to the network.",
81
+ (frozenset({"secret:passthrough"}), frozenset({"skill:exfil-shape", "skill:wants-secrets", "skill:remote-exec"})),
82
+ "the forwarded provider keys",
83
+ ),
84
+ Rule(
85
+ "Injected instructions in a skill → agent acts against you",
86
+ "Text the model reads contains instructions a human would not see. With approvals weakened there is nothing between those instructions and your shell.",
87
+ (frozenset({"skill:injection"}), frozenset({"exec:noapproval", "exec:noapproval:cron", "exec:host"})),
88
+ "command execution as your user",
89
+ ),
90
+ Rule(
91
+ "SSRF → local services → agent",
92
+ "The agent's web tools may be pointed at localhost, and something is listening there. A hostile web page becomes a client of your own services.",
93
+ (frozenset({"ssrf:off"}), frozenset({"net:loopback", "net:unauth", "net:unauth:verified"})),
94
+ "whatever the loopback services expose — including the agent's own API",
95
+ ),
96
+ Rule(
97
+ "Local user → readable credentials",
98
+ "No exploit needed: another account or process on this host just reads the file.",
99
+ (frozenset({"secret:sprawl:readable", "secret:vault-readable"}),),
100
+ "the credentials in the listed files",
101
+ ),
102
+ Rule(
103
+ "Local user → gateway socket → agent as you",
104
+ "The control socket is writable by others. Connecting to it is driving the agent with your identity.",
105
+ (frozenset({"local:gateway-socket"}), frozenset({"secret:procenv", "secret:vault", "exec:host"})),
106
+ "command execution as your user, and every credential",
107
+ ),
108
+ ]
109
+
110
+
111
+ def _index(findings: list[Finding]) -> dict[str, list[Finding]]:
112
+ idx: dict[str, list[Finding]] = {}
113
+ for f in findings:
114
+ for t in f.tags:
115
+ idx.setdefault(t, []).append(f)
116
+ return idx
117
+
118
+
119
+ def build_attack_paths(report: ScanReport) -> list[AttackPath]:
120
+ idx = _index(report.findings)
121
+ vault = [f for f in report.findings if "secret:vault" in f.tags]
122
+ reach_detail = ""
123
+ if vault and vault[0].secrets:
124
+ kinds: dict[str, int] = {}
125
+ for s in vault[0].secrets:
126
+ kinds[s.kind] = kinds.get(s.kind, 0) + 1
127
+ reach_detail = " (" + ", ".join(f"{k}×{n}" for k, n in sorted(kinds.items())) + ")"
128
+ paths: list[AttackPath] = []
129
+ for rule in RULES:
130
+ hops: list[Finding] = []
131
+ used: set[int] = set()
132
+ for n, hop in enumerate(rule.hops):
133
+ last = n == len(rule.hops) - 1
134
+ floor = Severity.MEDIUM.rank if n == 0 else (Severity.INFO.rank if last else Severity.LOW.rank)
135
+ candidates = [f for t in hop for f in idx.get(t, []) if id(f) not in used and f.severity.rank >= floor]
136
+ if not candidates:
137
+ break
138
+ best = max(candidates, key=lambda f: f.severity.rank)
139
+ hops.append(best)
140
+ used.add(id(best))
141
+ else:
142
+ reaches = rule.reaches_default + (reach_detail if "credential" in rule.reaches_default else "")
143
+ paths.append(AttackPath(rule.name, rule.narrative, hops, reaches, kill_hop=1))
144
+ paths.sort(key=lambda p: -p.severity.rank)
145
+ return paths
146
+
147
+
148
+ def build_blast_radius(report: ScanReport) -> list[BlastEntry]:
149
+ """Per-kind summary of every credential the audit could see (vault + process env)."""
150
+ seen: dict[str, dict[str, RedactedSecret]] = {}
151
+ for f in report.findings:
152
+ if not ({"secret:vault", "secret:procenv"} & set(f.tags)):
153
+ continue
154
+ for s in f.secrets:
155
+ seen.setdefault(s.kind, {})[s.fingerprint] = s
156
+ out = [
157
+ BlastEntry(kind, len(v), BLAST.get(kind, BLAST["generic-credential"]), [s.display for s in list(v.values())[:5]])
158
+ for kind, v in seen.items()
159
+ ]
160
+ out.sort(key=lambda b: (-b.count, b.kind))
161
+ return out
File without changes
@@ -0,0 +1,63 @@
1
+ """Shared filesystem helpers for checks. Never follows symlinks below the root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+
10
+ def _guard_root(root: Path) -> bool:
11
+ """The root must exist and must not itself be a symlink (discovery resolved it)."""
12
+ return root.exists() and not root.is_symlink()
13
+
14
+
15
+ def _prune(d: Path, root: Path, dirnames: list[str], exclude: set[str], exclude_root: set[str]) -> None:
16
+ banned = exclude | (exclude_root if d == root else set())
17
+ dirnames[:] = [n for n in dirnames if n not in banned and not (d / n).is_symlink()]
18
+
19
+
20
+ def walk_files(root: Path, exclude: set[str], max_depth: int = 4, exclude_root: set[str] = frozenset()) -> Iterator[Path]:
21
+ root = Path(root)
22
+ if not _guard_root(root):
23
+ return
24
+ if root.is_file():
25
+ yield root
26
+ return
27
+ base_depth = len(root.parts)
28
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
29
+ d = Path(dirpath)
30
+ if len(d.parts) - base_depth >= max_depth:
31
+ dirnames[:] = []
32
+ _prune(d, root, dirnames, exclude, exclude_root)
33
+ for fn in filenames:
34
+ p = d / fn
35
+ if p.is_symlink():
36
+ continue
37
+ yield p
38
+
39
+
40
+ def walk_entries(root: Path, exclude: set[str], max_depth: int = 3, exclude_root: set[str] = frozenset()) -> Iterator[Path]:
41
+ """Files *and* directories below root (not root itself)."""
42
+ root = Path(root)
43
+ if not _guard_root(root) or not root.is_dir():
44
+ return
45
+ base_depth = len(root.parts)
46
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
47
+ d = Path(dirpath)
48
+ if len(d.parts) - base_depth >= max_depth:
49
+ dirnames[:] = []
50
+ _prune(d, root, dirnames, exclude, exclude_root)
51
+ for n in dirnames:
52
+ yield d / n
53
+ for fn in filenames:
54
+ p = d / fn
55
+ if not p.is_symlink():
56
+ yield p
57
+
58
+
59
+ def rel(home: Path, p: Path) -> str:
60
+ try:
61
+ return str(p.relative_to(home))
62
+ except ValueError:
63
+ return str(p)
@@ -0,0 +1,75 @@
1
+ """ADV-001: version staleness and dismissed advisories — from Hermes's own local
2
+ cache only. daemonaudit never contacts the network."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from datetime import datetime, timezone
8
+
9
+ from daemonaudit.discover.hermes_config import load_settings
10
+ from daemonaudit.model import CheckOutput, Finding, Position, Severity, Target
11
+ from daemonaudit.platform import NotSupported, Platform
12
+ from daemonaudit.registry import check
13
+
14
+
15
+ @check("ADV-001", "Outdated daemon or dismissed security advisories", Position.CONTENT)
16
+ def advisories(target: Target, plat: Platform) -> CheckOutput:
17
+ out = CheckOutput()
18
+ settings = load_settings(target, plat)
19
+ p = target.home / ".update_check"
20
+ try:
21
+ raw = plat.read_nofollow(p, 64 * 1024)
22
+ except NotSupported:
23
+ raw = p.read_bytes() if p.exists() else b""
24
+ except FileNotFoundError:
25
+ raw = b""
26
+ except OSError as e:
27
+ out.note(f".update_check unreadable ({e.strerror or e})")
28
+ raw = b""
29
+ if raw:
30
+ try:
31
+ d = json.loads(raw.decode("utf-8", "replace"))
32
+ behind = int(d.get("behind") or 0)
33
+ ts = d.get("ts")
34
+ age = ""
35
+ if isinstance(ts, (int, float)):
36
+ days = (datetime.now(timezone.utc) - datetime.fromtimestamp(ts, timezone.utc)).days
37
+ age = f", checked {days} day(s) ago"
38
+ if behind > 0:
39
+ out.findings.append(
40
+ Finding(
41
+ check_id="ADV-001",
42
+ title=f"Hermes is {behind} update(s) behind (v{d.get('ver') or target.version or '?'}{age})",
43
+ severity=Severity.LOW if behind < 10 else Severity.MEDIUM,
44
+ position=Position.CONTENT,
45
+ asset=str(p),
46
+ why=(
47
+ "Per Hermes's own update check. Agent frameworks ship security fixes often "
48
+ "(gateway auth, injection guards, dependency advisories); running behind means running known bugs."
49
+ ),
50
+ fix="hermes update # then restart the gateway",
51
+ verify_cmd="hermes doctor",
52
+ evidence=[f"behind={behind}"],
53
+ )
54
+ )
55
+ except (ValueError, TypeError):
56
+ out.note(".update_check is not valid JSON")
57
+ else:
58
+ out.note(".update_check absent — Hermes has not run its update check")
59
+
60
+ acked = settings.get("security.acked_advisories") or []
61
+ if acked:
62
+ out.findings.append(
63
+ Finding(
64
+ check_id="ADV-001",
65
+ title=f"{len(acked)} security advisory(ies) have been dismissed",
66
+ severity=Severity.INFO,
67
+ position=Position.CONTENT,
68
+ asset=str(target.home / "config.yaml"),
69
+ why="`hermes doctor --ack` hides an advisory permanently. Make sure each one was resolved, not just silenced.",
70
+ fix="Review: remove ids from security.acked_advisories in config.yaml to see them again.",
71
+ verify_cmd="hermes doctor",
72
+ evidence=[str(a) for a in acked[:10]],
73
+ )
74
+ )
75
+ return out
@@ -0,0 +1,149 @@
1
+ """NET-001: what the daemon listens on. NET-002: unix socket permissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+
7
+ from daemonaudit.checks._walk import rel, walk_entries
8
+ from daemonaudit.discover.hermes import DEFAULT_PORTS
9
+ from daemonaudit.discover.hermes_config import load_settings
10
+ from daemonaudit.model import CheckOutput, Finding, Position, Severity, Target
11
+ from daemonaudit.platform import Platform
12
+ from daemonaudit.registry import check
13
+
14
+
15
+ def _is_loopback(ip: str) -> bool:
16
+ try:
17
+ return ipaddress.ip_address(ip.split("%")[0]).is_loopback
18
+ except ValueError:
19
+ return ip in ("localhost",)
20
+
21
+
22
+ def _is_wildcard(ip: str) -> bool:
23
+ return ip in ("0.0.0.0", "::", "*", "")
24
+
25
+
26
+ @check("NET-001", "Daemon listeners reachable from the network", Position.REMOTE)
27
+ def listeners(target: Target, plat: Platform) -> CheckOutput:
28
+ out = CheckOutput()
29
+ settings = load_settings(target, plat)
30
+ pids = set(target.pids)
31
+ names = {}
32
+ for pid in list(pids):
33
+ for c in plat.children(pid):
34
+ pids.add(c["pid"])
35
+ names[c["pid"]] = c["name"]
36
+ known_ports = dict(DEFAULT_PORTS)
37
+ api_port, _ = settings.env("API_SERVER_PORT")
38
+ if api_port and api_port.isdigit():
39
+ known_ports["api_server"] = int(api_port)
40
+ port_names = {v: k for k, v in known_ports.items()}
41
+
42
+ sockets = plat.listening_sockets() # NotSupported → registry marks skip
43
+ ours = [s for s in sockets if (s["pid"] in pids) or (s["port"] in port_names)]
44
+
45
+ if not target.pids:
46
+ out.findings.append(
47
+ Finding(
48
+ check_id="NET-001",
49
+ title="Daemon is not running — network exposure could not be observed",
50
+ severity=Severity.INFO,
51
+ position=Position.REMOTE,
52
+ asset=str(target.home),
53
+ why="Listeners can only be attributed to the daemon while it runs. Only well-known daemon ports were checked.",
54
+ fix="Re-run the audit while the gateway is running for a complete picture.",
55
+ verify_cmd="daemonaudit scan # with the gateway up",
56
+ )
57
+ )
58
+
59
+ loop: list[str] = []
60
+ for s in sorted(ours, key=lambda s: (s["port"], s["ip"])):
61
+ who = port_names.get(s["port"], names.get(s["pid"], "daemon"))
62
+ label = f"{s['ip']}:{s['port']} ({who}, pid {s['pid']})"
63
+ if _is_loopback(s["ip"]):
64
+ loop.append(label)
65
+ continue
66
+ exposure = "every interface" if _is_wildcard(s["ip"]) else f"the {s['ip']} interface"
67
+ api_unauth = s["port"] == known_ports["api_server"] and not settings.env_set("API_SERVER_KEY")
68
+ out.findings.append(
69
+ Finding(
70
+ check_id="NET-001",
71
+ title=f"Daemon port {s['port']} ({who}) is bound to {exposure}",
72
+ severity=Severity.CRITICAL if api_unauth else Severity.HIGH,
73
+ position=Position.REMOTE,
74
+ asset=f"{s['ip']}:{s['port']}",
75
+ why=(
76
+ f"Anything that can reach this host on port {s['port']} can talk to the daemon directly. "
77
+ + ("The API server has no API_SERVER_KEY, so that access is unauthenticated: remote prompt → tool use. "
78
+ if api_unauth else "Whether that is safe depends entirely on the auth in front of it. ")
79
+ + "On a laptop this includes every network you join."
80
+ ),
81
+ fix=(
82
+ "Bind to 127.0.0.1 (e.g. API_SERVER_HOST=127.0.0.1) and reach it over SSH/Tailscale, "
83
+ "or put it behind an authenticating reverse proxy. Set API_SERVER_KEY if the API server is enabled."
84
+ ),
85
+ verify_cmd=f"ss -tlnp 2>/dev/null | grep ':{s['port']} ' || lsof -nP -iTCP:{s['port']} -sTCP:LISTEN",
86
+ evidence=[label],
87
+ tags=["net:public"] + (["net:unauth"] if api_unauth else []),
88
+ )
89
+ )
90
+ if loop:
91
+ out.findings.append(
92
+ Finding(
93
+ check_id="NET-001",
94
+ title=f"{len(loop)} daemon listener(s) are loopback-only",
95
+ severity=Severity.INFO,
96
+ position=Position.REMOTE,
97
+ asset=str(target.home),
98
+ why="Loopback listeners are reachable only by processes on this host. That is the right default; listed for inventory.",
99
+ fix="Nothing to do — keep them this way.",
100
+ evidence=loop[:15],
101
+ tags=["net:loopback"],
102
+ )
103
+ )
104
+ return out
105
+
106
+
107
+ @check("NET-002", "Unix sockets accessible to other users", Position.LOCAL)
108
+ def unix_sockets(target: Target, plat: Platform) -> CheckOutput:
109
+ if not plat.posix_modes:
110
+ from daemonaudit.registry import Skipped
111
+
112
+ raise Skipped(f"{plat.name}: socket permission bits unavailable")
113
+ out = CheckOutput()
114
+ home, lay = target.home, target.layout
115
+ for p in walk_entries(home, lay.exclude_dirs, max_depth=3, exclude_root=lay.exclude_root_dirs):
116
+ try:
117
+ m = plat.file_mode(p)
118
+ except OSError as e:
119
+ out.note(f"cannot stat {p} ({e.strerror or e})")
120
+ continue
121
+ if not m.is_socket:
122
+ continue
123
+ if m.other_writable:
124
+ sev, who = Severity.HIGH, "any local user"
125
+ elif m.group_writable:
126
+ sev, who = Severity.LOW, "the file's group"
127
+ else:
128
+ continue
129
+ r = rel(home, p)
130
+ gateway = p.name == "gateway.sock"
131
+ out.findings.append(
132
+ Finding(
133
+ check_id="NET-002",
134
+ title=f"Socket {r} is writable by {who} (mode {m.octal})",
135
+ severity=Severity.HIGH if (gateway and m.group_writable) else sev,
136
+ position=Position.LOCAL,
137
+ asset=str(p),
138
+ why=(
139
+ "Connecting to a unix socket needs write permission on it. "
140
+ + ("This is the gateway control socket: whoever can write to it can drive the agent as you. "
141
+ if gateway else "Local processes running as other users can send it messages.")
142
+ ),
143
+ fix=f"chmod 600 {p} # and set umask 077 for the daemon so it is created that way",
144
+ verify_cmd=plat.stat_cmd(p),
145
+ evidence=[f"mode {m.octal}"],
146
+ tags=["local:gateway-socket"] if gateway else [],
147
+ )
148
+ )
149
+ return out