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 +40 -0
- drosera/__main__.py +6 -0
- drosera/canary/__init__.py +1 -0
- drosera/canary/mint.py +281 -0
- drosera/canary/watch.py +217 -0
- drosera/cli.py +548 -0
- drosera/config.py +223 -0
- drosera/detect/__init__.py +1 -0
- drosera/detect/engine.py +256 -0
- drosera/detect/rules.py +341 -0
- drosera/detect/signals.py +308 -0
- drosera/lure/__init__.py +1 -0
- drosera/lure/nectar.py +306 -0
- drosera/middleware/__init__.py +1 -0
- drosera/middleware/asgi.py +162 -0
- drosera/middleware/wsgi.py +151 -0
- drosera/models.py +291 -0
- drosera/py.typed +0 -0
- drosera/server/__init__.py +1 -0
- drosera/server/app.py +316 -0
- drosera/snare.py +290 -0
- drosera/telemetry/__init__.py +1 -0
- drosera/telemetry/export.py +300 -0
- drosera/telemetry/sink.py +236 -0
- drosera/trap/__init__.py +1 -0
- drosera/trap/derail.py +81 -0
- drosera/trap/tarpit.py +183 -0
- drosera/util.py +142 -0
- drosera-0.1.0.dist-info/METADATA +321 -0
- drosera-0.1.0.dist-info/RECORD +35 -0
- drosera-0.1.0.dist-info/WHEEL +5 -0
- drosera-0.1.0.dist-info/entry_points.txt +2 -0
- drosera-0.1.0.dist-info/licenses/LICENSE +202 -0
- drosera-0.1.0.dist-info/licenses/NOTICE +5 -0
- drosera-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
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}
|
drosera/canary/watch.py
ADDED
|
@@ -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)
|