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.
- daemonaudit/__init__.py +3 -0
- daemonaudit/__main__.py +4 -0
- daemonaudit/banner.py +13 -0
- daemonaudit/chain/__init__.py +3 -0
- daemonaudit/chain/rules.py +161 -0
- daemonaudit/checks/__init__.py +0 -0
- daemonaudit/checks/_walk.py +63 -0
- daemonaudit/checks/advisories.py +75 -0
- daemonaudit/checks/network.py +149 -0
- daemonaudit/checks/perms.py +245 -0
- daemonaudit/checks/policy.py +371 -0
- daemonaudit/checks/secrets.py +127 -0
- daemonaudit/checks/skills.py +464 -0
- daemonaudit/cli.py +110 -0
- daemonaudit/discover/__init__.py +11 -0
- daemonaudit/discover/hermes.py +104 -0
- daemonaudit/discover/hermes_config.py +155 -0
- daemonaudit/model.py +288 -0
- daemonaudit/platform/__init__.py +3 -0
- daemonaudit/platform/base.py +359 -0
- daemonaudit/probes/__init__.py +0 -0
- daemonaudit/probes/red.py +241 -0
- daemonaudit/redact.py +314 -0
- daemonaudit/registry.py +93 -0
- daemonaudit/report/__init__.py +0 -0
- daemonaudit/report/html.py +125 -0
- daemonaudit/report/json_out.py +11 -0
- daemonaudit/report/terminal.py +121 -0
- daemonaudit-0.1.0.dist-info/METADATA +113 -0
- daemonaudit-0.1.0.dist-info/RECORD +33 -0
- daemonaudit-0.1.0.dist-info/WHEEL +4 -0
- daemonaudit-0.1.0.dist-info/entry_points.txt +2 -0
- daemonaudit-0.1.0.dist-info/licenses/LICENSE +21 -0
daemonaudit/__init__.py
ADDED
daemonaudit/__main__.py
ADDED
daemonaudit/banner.py
ADDED
|
@@ -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
|