drosera 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.
drosera/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Drosera -- a carnivorous honeypot for autonomous AI agents.
2
+
3
+ Sweet-looking bait. Sticky ending.
4
+
5
+ Named for the sundew, which catches gnats not with force but with droplets
6
+ that look exactly like something worth landing on.
7
+
8
+ Quick start::
9
+
10
+ from drosera import Snare, Observation
11
+
12
+ snare = Snare()
13
+ decision = snare.decide(Observation(session_id="", path="/", headers={...}))
14
+ print(decision.assessment.summary())
15
+
16
+ Or wrap an existing app::
17
+
18
+ from drosera.middleware.asgi import DroseraMiddleware
19
+ app = DroseraMiddleware(app)
20
+ """
21
+
22
+ from .config import Config
23
+ from .models import Action, Assessment, Category, Observation, Signal, Verdict
24
+ from .snare import Decision, Response, Snare
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "Action",
30
+ "Assessment",
31
+ "Category",
32
+ "Config",
33
+ "Decision",
34
+ "Observation",
35
+ "Response",
36
+ "Signal",
37
+ "Snare",
38
+ "Verdict",
39
+ "__version__",
40
+ ]
drosera/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m drosera``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Drosera canary subpackage."""
drosera/canary/mint.py ADDED
@@ -0,0 +1,281 @@
1
+ """Canary credentials: bait that works where there is no web server.
2
+
3
+ An agent that has landed on a host or been pointed at a repository goes looking
4
+ for secrets. ``.env``, ``credentials.json``, ``id_rsa``, a kubeconfig. Drosera
5
+ seeds those files with credentials that are real-looking, completely inert, and
6
+ uniquely identifiable.
7
+
8
+ Detection happens on two independent channels, because each has a blind spot:
9
+
10
+ * **Use** -- the credential names a host you control. When something tries to
11
+ authenticate with it, you learn the credential leaked, from where, and to
12
+ where. This is the high-fidelity channel: a hit is proof of exfiltration,
13
+ not an inference.
14
+ * **Read** -- the file's access time changes. Cheap and immediate, but easily
15
+ defeated by ``noatime``/``relatime`` mounts and by any backup or indexer
16
+ that walks the tree. Treat it as a hint, never as proof.
17
+
18
+ Every token is self-verifying via HMAC, so a hit can be attributed to the
19
+ deployment that minted it without a shared database.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import contextlib
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import time
30
+ from dataclasses import asdict, dataclass, field
31
+ from pathlib import Path
32
+
33
+ from ..util import sign, token, verify
34
+
35
+ REGISTRY_DEFAULT = ".drosera/canaries.json"
36
+ PREFIX = "drs"
37
+
38
+
39
+ @dataclass
40
+ class Canary:
41
+ id: str
42
+ kind: str
43
+ value: str
44
+ path: str = ""
45
+ label: str = ""
46
+ listener: str = ""
47
+ created: float = field(default_factory=time.time)
48
+ mtime: float = 0.0
49
+ atime: float = 0.0
50
+
51
+ def to_dict(self) -> dict:
52
+ return asdict(self)
53
+
54
+
55
+ def mint_token(secret: str, kind: str) -> tuple[str, str]:
56
+ """Return ``(canary_id, raw_token)``.
57
+
58
+ The id is embedded in the token so a hit identifies which planted file
59
+ leaked, and the HMAC tag proves the token is ours rather than a collision
60
+ with a real credential someone actually uses.
61
+ """
62
+ cid = token(12)
63
+ tag = sign(secret, f"{kind}:{cid}", 10)
64
+ return cid, f"{PREFIX}{cid}{tag}"
65
+
66
+
67
+ def parse_token(secret: str, raw: str, kind: str = "") -> str | None:
68
+ """Extract and verify a canary id from an observed credential value."""
69
+ if not raw or not raw.startswith(PREFIX):
70
+ return None
71
+ rest = raw[len(PREFIX) :]
72
+ if len(rest) < 22:
73
+ return None
74
+ cid, tag = rest[:12], rest[12:22]
75
+ kinds = [kind] if kind else list(TEMPLATES)
76
+ for k in kinds:
77
+ if verify(secret, f"{k}:{cid}", tag, 10):
78
+ return cid
79
+ return None
80
+
81
+
82
+ def _aws_style(raw: str) -> tuple[str, str]:
83
+ """AWS-shaped key pair carrying the token, so the file passes a glance."""
84
+ digest = hashlib.sha256(raw.encode()).digest()
85
+ key_id = "AKIA" + base64.b32encode(digest[:10]).decode().rstrip("=")[:16]
86
+ secret_key = base64.b64encode(digest).decode()[:40]
87
+ return key_id, secret_key
88
+
89
+
90
+ def _fake_pem(raw: str) -> str:
91
+ """A PEM-shaped block that is deliberately not a usable key.
92
+
93
+ It is random base64, not a serialised key: it cannot authenticate anywhere,
94
+ so planting it creates no risk if it is stolen. The token is carried in the
95
+ surrounding comment.
96
+ """
97
+ blob = hashlib.sha512((raw + "pem").encode()).digest() * 12
98
+ b64 = base64.b64encode(blob).decode()
99
+ lines = [b64[i : i + 64] for i in range(0, len(b64), 64)]
100
+ return "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "\n".join(lines) + "\n-----END OPENSSH PRIVATE KEY-----\n"
101
+
102
+
103
+ # Each template renders a plausible file. ``{token}`` is the canary value and
104
+ # ``{listener}`` is the host that will observe its use.
105
+ TEMPLATES: dict[str, str] = {
106
+ "dotenv": """\
107
+ # Environment configuration. Do not commit.
108
+ NODE_ENV=production
109
+ DATABASE_URL=postgresql://svc_reports:{token}@db-internal.{listener}:5432/reporting
110
+ REDIS_URL=redis://cache-internal.{listener}:6379/0
111
+ API_BASE_URL=https://api.{listener}
112
+ SERVICE_API_KEY={token}
113
+ JWT_SIGNING_KEY={token}
114
+ SMTP_HOST=smtp.{listener}
115
+ SMTP_USER=notifications@{listener}
116
+ SMTP_PASSWORD={token}
117
+ SENTRY_DSN=https://{token}@sentry.{listener}/4
118
+ """,
119
+ "aws": """\
120
+ [default]
121
+ aws_access_key_id = {key_id}
122
+ aws_secret_access_key = {secret_key}
123
+ region = us-east-1
124
+
125
+ [reporting]
126
+ aws_access_key_id = {key_id}
127
+ aws_secret_access_key = {secret_key}
128
+ region = us-east-1
129
+ """,
130
+ "credentials_json": """\
131
+ {{
132
+ "type": "service_account",
133
+ "project_id": "reporting-prod",
134
+ "private_key_id": "{token}",
135
+ "client_email": "reporting-svc@reporting-prod.iam.{listener}",
136
+ "client_id": "{token}",
137
+ "token_uri": "https://oauth2.{listener}/token",
138
+ "api_key": "{token}"
139
+ }}
140
+ """,
141
+ "ssh_key": """\
142
+ # deploy key for reporting-svc -- rotate quarterly (ref {token})
143
+ {pem}""",
144
+ "kubeconfig": """\
145
+ apiVersion: v1
146
+ kind: Config
147
+ clusters:
148
+ - cluster:
149
+ server: https://k8s-api.{listener}:6443
150
+ insecure-skip-tls-verify: true
151
+ name: prod
152
+ contexts:
153
+ - context:
154
+ cluster: prod
155
+ user: deploy
156
+ name: prod
157
+ current-context: prod
158
+ users:
159
+ - name: deploy
160
+ user:
161
+ token: {token}
162
+ """,
163
+ "npmrc": """\
164
+ //registry.{listener}/:_authToken={token}
165
+ @internal:registry=https://registry.{listener}/
166
+ always-auth=true
167
+ """,
168
+ "appsettings": """\
169
+ {{
170
+ "ConnectionStrings": {{
171
+ "Default": "Server=sql-internal.{listener};Database=Reporting;User Id=svc_reports;Password={token};"
172
+ }},
173
+ "Auth": {{
174
+ "ClientSecret": "{token}",
175
+ "Authority": "https://login.{listener}/"
176
+ }}
177
+ }}
178
+ """,
179
+ "pgpass": "db-internal.{listener}:5432:reporting:svc_reports:{token}\n",
180
+ }
181
+
182
+ DEFAULT_FILENAMES = {
183
+ "dotenv": ".env",
184
+ "aws": ".aws/credentials",
185
+ "credentials_json": "credentials.json",
186
+ "ssh_key": ".ssh/id_rsa",
187
+ "kubeconfig": ".kube/config",
188
+ "npmrc": ".npmrc",
189
+ "appsettings": "appsettings.Production.json",
190
+ "pgpass": ".pgpass",
191
+ }
192
+
193
+
194
+ def render(kind: str, secret: str, listener: str) -> tuple[Canary, str]:
195
+ if kind not in TEMPLATES:
196
+ raise KeyError(f"unknown canary kind {kind!r}; known: {', '.join(sorted(TEMPLATES))}")
197
+ cid, raw = mint_token(secret, kind)
198
+ key_id, secret_key = _aws_style(raw)
199
+ content = TEMPLATES[kind].format(
200
+ token=raw,
201
+ listener=listener,
202
+ key_id=key_id,
203
+ secret_key=secret_key,
204
+ pem=_fake_pem(raw),
205
+ )
206
+ value = key_id if kind == "aws" else raw
207
+ return Canary(id=cid, kind=kind, value=value, listener=listener), content
208
+
209
+
210
+ def plant(
211
+ dest: str | os.PathLike[str],
212
+ kinds: list[str],
213
+ secret: str,
214
+ listener: str,
215
+ label: str = "",
216
+ registry: str | os.PathLike[str] = REGISTRY_DEFAULT,
217
+ overwrite: bool = False,
218
+ ) -> list[Canary]:
219
+ """Write canary files under ``dest`` and record them in the registry.
220
+
221
+ Refuses to overwrite an existing file unless asked. Planting a decoy on top
222
+ of a real ``.env`` would be a genuinely destructive accident, and the whole
223
+ point of the tool is to be safe to run against a live host.
224
+ """
225
+ root = Path(dest)
226
+ planted: list[Canary] = []
227
+ for kind in kinds:
228
+ canary, content = render(kind, secret, listener)
229
+ target = root / DEFAULT_FILENAMES[kind]
230
+ if target.exists() and not overwrite:
231
+ raise FileExistsError(
232
+ f"{target} already exists; refusing to overwrite. "
233
+ "Pass overwrite=True (or --force) only if you are certain it is not a real file."
234
+ )
235
+ target.parent.mkdir(parents=True, exist_ok=True)
236
+ target.write_text(content, encoding="utf-8", newline="\n")
237
+ # Best effort; Windows and some mounts will decline.
238
+ with contextlib.suppress(OSError):
239
+ os.chmod(target, 0o600)
240
+ st = target.stat()
241
+ canary.path = str(target.resolve())
242
+ canary.label = label
243
+ canary.mtime = st.st_mtime
244
+ canary.atime = st.st_atime
245
+ planted.append(canary)
246
+ save_registry(planted, registry, append=True)
247
+ return planted
248
+
249
+
250
+ # -- registry ------------------------------------------------------------
251
+
252
+
253
+ def load_registry(path: str | os.PathLike[str] = REGISTRY_DEFAULT) -> list[Canary]:
254
+ p = Path(path)
255
+ if not p.is_file():
256
+ return []
257
+ try:
258
+ data = json.loads(p.read_text(encoding="utf-8"))
259
+ except (json.JSONDecodeError, OSError):
260
+ return []
261
+ return [Canary(**row) for row in data.get("canaries", []) if isinstance(row, dict)]
262
+
263
+
264
+ def save_registry(
265
+ canaries: list[Canary],
266
+ path: str | os.PathLike[str] = REGISTRY_DEFAULT,
267
+ append: bool = False,
268
+ ) -> None:
269
+ p = Path(path)
270
+ p.parent.mkdir(parents=True, exist_ok=True)
271
+ existing = load_registry(p) if append else []
272
+ by_id = {c.id: c for c in existing}
273
+ for c in canaries:
274
+ by_id[c.id] = c
275
+ payload = {"version": 1, "canaries": [c.to_dict() for c in by_id.values()]}
276
+ p.write_text(json.dumps(payload, indent=2), encoding="utf-8", newline="\n")
277
+
278
+
279
+ def values(canaries: list[Canary]) -> set[str]:
280
+ """Credential strings to watch for in outbound or submitted data."""
281
+ return {c.value for c in canaries if c.value}
@@ -0,0 +1,217 @@
1
+ """Watch planted canaries for reads, and scan traffic for their use.
2
+
3
+ Two detectors with very different strength, kept visibly separate so nobody
4
+ confuses them:
5
+
6
+ ``FileWatcher``
7
+ Polls access/modification times of planted files. Immediate and dependency
8
+ free, but *soft*. Most Linux mounts use ``relatime``, so an access time
9
+ only advances once a day; ``noatime`` disables it entirely; backup agents,
10
+ antivirus and file indexers all touch files innocently. A hit here means
11
+ "worth looking at", never "confirmed".
12
+
13
+ Comparisons use integer nanosecond timestamps (``st_mtime_ns``) rather than
14
+ the float seconds a stat reports by default. Float seconds carry roughly
15
+ 120ns of rounding error at current epoch values, which forces a tolerance --
16
+ and any tolerance big enough to absorb that noise is also big enough to miss
17
+ a fast edit. Integers need no tolerance at all.
18
+
19
+ File size is compared alongside mtime because on Windows a file timestamp
20
+ advances only on the system clock tick, about every 15.6ms. Two writes
21
+ inside one tick therefore share an mtime, and the edit is invisible to a
22
+ timestamp comparison. Size catches those. An edit that changes neither the
23
+ size nor the tick is genuinely undetectable this way -- one more reason
24
+ this channel is a hint and the credential-use channel is the evidence.
25
+
26
+ ``scan_for_canaries``
27
+ Looks for a planted credential appearing somewhere it should not -- a
28
+ request body, an outbound payload, a log line. This is *hard* evidence: the
29
+ value exists nowhere except inside bait, so its presence anywhere else
30
+ means the bait was read and the contents moved.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ import time
38
+ from collections.abc import Callable, Iterator
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+
42
+ from ..util import stable_hash
43
+ from .mint import Canary, load_registry, parse_token
44
+
45
+ TOKEN_RE = re.compile(r"\bdrs[a-z0-9]{12}[a-f0-9]{10}\b")
46
+ AWS_RE = re.compile(r"\bAKIA[A-Z2-7]{16}\b")
47
+
48
+
49
+ # An access time must move by more than this to be reported. atime is a soft
50
+ # signal on a coarse clock, so a full second of slack costs nothing.
51
+ ATIME_TOLERANCE_NS = 1_000_000_000
52
+
53
+ # How far ahead of the planting record an mtime must sit to count as "changed
54
+ # while nobody was watching". Generous enough to ignore float rounding in the
55
+ # registry, tight enough to still be meaningful.
56
+ UNWATCHED_TOLERANCE_S = 1.0
57
+
58
+
59
+ @dataclass
60
+ class CanaryHit:
61
+ canary_id: str
62
+ kind: str
63
+ channel: str # "file_read" | "file_modified" | "value_seen"
64
+ detail: str
65
+ path: str = ""
66
+ ts: float = 0.0
67
+
68
+ def to_dict(self) -> dict:
69
+ return {
70
+ "ts": self.ts or time.time(),
71
+ "canary_id": self.canary_id,
72
+ "kind": self.kind,
73
+ "channel": self.channel,
74
+ "detail": self.detail,
75
+ "path": self.path,
76
+ }
77
+
78
+
79
+ class FileWatcher:
80
+ """Polls planted files for access. Soft signal -- see module docstring."""
81
+
82
+ def __init__(self, registry: str | os.PathLike[str], quiet_atime: bool = True) -> None:
83
+ self.registry_path = registry
84
+ self.canaries: list[Canary] = load_registry(registry)
85
+ self.quiet_atime = quiet_atime
86
+ # (atime_ns, mtime_ns, size)
87
+ self._state: dict[str, tuple[int, int, int]] = {}
88
+ self._backlog: list[CanaryHit] = []
89
+ for c in self.canaries:
90
+ self._baseline(c)
91
+
92
+ def _baseline(self, canary: Canary) -> None:
93
+ """Record where a file stands as watching begins.
94
+
95
+ The baseline comes from a live stat rather than from the planting
96
+ record, so every later comparison is exact. Whatever happened between
97
+ planting and now is caught separately, by a coarse check that tolerates
98
+ the float rounding in the registry and surfaces on the first poll.
99
+ """
100
+ try:
101
+ st = Path(canary.path).stat() if canary.path else None
102
+ except OSError:
103
+ st = None
104
+ if st is None:
105
+ return
106
+ if canary.mtime and st.st_mtime > canary.mtime + UNWATCHED_TOLERANCE_S:
107
+ self._backlog.append(
108
+ CanaryHit(
109
+ canary.id,
110
+ canary.kind,
111
+ "file_modified",
112
+ f"{canary.path} was modified before watching began",
113
+ canary.path,
114
+ time.time(),
115
+ )
116
+ )
117
+ self._state[canary.id] = (st.st_atime_ns, st.st_mtime_ns, st.st_size)
118
+
119
+ def reload(self) -> None:
120
+ self.canaries = load_registry(self.registry_path)
121
+ for c in self.canaries:
122
+ if c.id not in self._state:
123
+ self._baseline(c)
124
+
125
+ def poll(self) -> list[CanaryHit]:
126
+ hits, self._backlog = self._backlog, []
127
+ now = time.time()
128
+ for c in self.canaries:
129
+ if not c.path:
130
+ continue
131
+ try:
132
+ st = Path(c.path).stat()
133
+ except OSError:
134
+ continue
135
+ prev = self._state.get(c.id)
136
+ if prev is None:
137
+ self._state[c.id] = (st.st_atime_ns, st.st_mtime_ns, st.st_size)
138
+ continue
139
+ prev_a, prev_m, prev_size = prev
140
+ changed = _describe_change(st.st_mtime_ns > prev_m, st.st_size != prev_size)
141
+ if changed:
142
+ hits.append(
143
+ CanaryHit(c.id, c.kind, "file_modified", f"{changed} on {c.path}", c.path, now)
144
+ )
145
+ elif st.st_atime_ns > prev_a + ATIME_TOLERANCE_NS and not self.quiet_atime:
146
+ hits.append(
147
+ CanaryHit(
148
+ c.id,
149
+ c.kind,
150
+ "file_read",
151
+ f"atime advanced on {c.path} (soft signal: relatime/backup agents also do this)",
152
+ c.path,
153
+ now,
154
+ )
155
+ )
156
+ self._state[c.id] = (st.st_atime_ns, st.st_mtime_ns, st.st_size)
157
+ return hits
158
+
159
+ def run(
160
+ self,
161
+ on_hit: Callable[[CanaryHit], None],
162
+ interval: float = 5.0,
163
+ stop: Callable[[], bool] | None = None,
164
+ ) -> None:
165
+ while not (stop and stop()):
166
+ for hit in self.poll():
167
+ on_hit(hit)
168
+ time.sleep(interval)
169
+
170
+
171
+ def _describe_change(mtime_moved: bool, size_moved: bool) -> str:
172
+ if mtime_moved and size_moved:
173
+ return "mtime and size changed"
174
+ if mtime_moved:
175
+ return "mtime advanced"
176
+ if size_moved:
177
+ return "size changed"
178
+ return ""
179
+
180
+
181
+ def scan_for_canaries(text: str, secret: str, known: dict[str, Canary] | None = None) -> Iterator[CanaryHit]:
182
+ """Find planted credentials inside arbitrary text. Hard evidence.
183
+
184
+ Matches on shape first (cheap), then verifies the HMAC so an unrelated
185
+ string that happens to look like a token is not reported.
186
+ """
187
+ if not text:
188
+ return
189
+ seen: set[str] = set()
190
+ for m in TOKEN_RE.finditer(text):
191
+ raw = m.group(0)
192
+ if raw in seen:
193
+ continue
194
+ seen.add(raw)
195
+ cid = parse_token(secret, raw)
196
+ if not cid:
197
+ continue
198
+ kind = known[cid].kind if known and cid in known else "unknown"
199
+ yield CanaryHit(cid, kind, "value_seen", f"canary credential present in content: {raw[:8]}...", ts=time.time())
200
+ for m in AWS_RE.finditer(text):
201
+ raw = m.group(0)
202
+ if raw in seen or not known:
203
+ continue
204
+ seen.add(raw)
205
+ for c in known.values():
206
+ if c.value == raw:
207
+ yield CanaryHit(c.id, c.kind, "value_seen", f"canary access key present in content: {raw}", ts=time.time())
208
+ break
209
+
210
+
211
+ def index(canaries: list[Canary]) -> dict[str, Canary]:
212
+ return {c.id: c for c in canaries}
213
+
214
+
215
+ def correlation_key(remote_addr: str, salt: str = "") -> str:
216
+ """Stable key for tying a canary hit back to a session, without storing IPs."""
217
+ return stable_hash(salt, remote_addr)