trappsec 0.1.0__tar.gz
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.
- trappsec-0.1.0/PKG-INFO +17 -0
- trappsec-0.1.0/pyproject.toml +23 -0
- trappsec-0.1.0/setup.cfg +4 -0
- trappsec-0.1.0/src/trappsec/__init__.py +5 -0
- trappsec-0.1.0/src/trappsec/builders.py +78 -0
- trappsec-0.1.0/src/trappsec/core.py +257 -0
- trappsec-0.1.0/src/trappsec/handlers.py +121 -0
- trappsec-0.1.0/src/trappsec/integrations/__init__.py +0 -0
- trappsec-0.1.0/src/trappsec/integrations/fastapi.py +118 -0
- trappsec-0.1.0/src/trappsec/integrations/flask.py +98 -0
- trappsec-0.1.0/src/trappsec.egg-info/PKG-INFO +17 -0
- trappsec-0.1.0/src/trappsec.egg-info/SOURCES.txt +13 -0
- trappsec-0.1.0/src/trappsec.egg-info/dependency_links.txt +1 -0
- trappsec-0.1.0/src/trappsec.egg-info/requires.txt +6 -0
- trappsec-0.1.0/src/trappsec.egg-info/top_level.txt +1 -0
trappsec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trappsec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: deception as a developer tool
|
|
5
|
+
Author-email: Nikhil Salgaonkar <nikhil@ftfy.co>
|
|
6
|
+
Project-URL: Homepage, https://trappsec.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/trappsec-dev/trappsec
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/trappsec-dev/trappsec/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: webhooks
|
|
15
|
+
Requires-Dist: requests; extra == "webhooks"
|
|
16
|
+
Provides-Extra: otel
|
|
17
|
+
Requires-Dist: opentelemetry-api; extra == "otel"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "trappsec"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="Nikhil Salgaonkar", email="nikhil@ftfy.co" },
|
|
6
|
+
]
|
|
7
|
+
description = "deception as a developer tool"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.7"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
"Homepage" = "https://trappsec.dev"
|
|
18
|
+
"Repository" = "https://github.com/trappsec-dev/trappsec"
|
|
19
|
+
"Bug Tracker" = "https://github.com/trappsec-dev/trappsec/issues"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
webhooks = ["requests"]
|
|
23
|
+
otel = ["opentelemetry-api"]
|
trappsec-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
import copy
|
|
3
|
+
|
|
4
|
+
NO_DEFAULT = object()
|
|
5
|
+
|
|
6
|
+
class TrapBuilder:
|
|
7
|
+
def __init__(self, ts, path):
|
|
8
|
+
self.ts = ts
|
|
9
|
+
self.config = {
|
|
10
|
+
"path": path,
|
|
11
|
+
"methods": ["GET", "POST"],
|
|
12
|
+
"intent": None,
|
|
13
|
+
"response.authenticated": copy.deepcopy(self.ts.default_responses["authenticated"]),
|
|
14
|
+
"response.unauthenticated": copy.deepcopy(self.ts.default_responses["unauthenticated"]),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def methods(self, *args):
|
|
18
|
+
self.config["methods"] = list(args)
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def intent(self, intent: str):
|
|
22
|
+
self.config["intent"] = intent
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def _respond(self, key: str, status: int = None, body: typing.Union[dict, typing.Callable] = None, mime_type: str = None, template: str = None):
|
|
26
|
+
key = "response." + key
|
|
27
|
+
if template:
|
|
28
|
+
if any(arg is not None for arg in (status, body, mime_type)):
|
|
29
|
+
raise TypeError("response_builder: `template` cannot be used together with `status`, `body`, or `mime_type`.")
|
|
30
|
+
|
|
31
|
+
tmpl = self.ts._templates.get(template)
|
|
32
|
+
if not tmpl:
|
|
33
|
+
raise ValueError(f"response_builder: template '{template}' not found.")
|
|
34
|
+
|
|
35
|
+
self.config[key] = copy.deepcopy(tmpl)
|
|
36
|
+
else:
|
|
37
|
+
if status:
|
|
38
|
+
self.config[key]["status_code"] = status
|
|
39
|
+
|
|
40
|
+
if body:
|
|
41
|
+
self.config[key]["response_body"] = body
|
|
42
|
+
|
|
43
|
+
if mime_type:
|
|
44
|
+
self.config[key]["mime_type"] = mime_type
|
|
45
|
+
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def respond(self, status: int = None, body: typing.Union[dict, typing.Callable] = None, mime_type: str = None, template: str = None):
|
|
49
|
+
self._respond("authenticated", status, body, mime_type, template)
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def if_unauthenticated(self, status: int = None, body: typing.Union[dict, typing.Callable] = None, mime_type: str = None, template: str = None):
|
|
53
|
+
self._respond("unauthenticated", status, body, mime_type, template)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def build(self):
|
|
57
|
+
return self.config
|
|
58
|
+
|
|
59
|
+
class WatchBuilder:
|
|
60
|
+
def __init__(self, path):
|
|
61
|
+
self.path = path
|
|
62
|
+
self._query = {}
|
|
63
|
+
self._body = {}
|
|
64
|
+
|
|
65
|
+
def query(self, name: str, default: typing.Any = NO_DEFAULT, intent: str = None):
|
|
66
|
+
self._query[name] = {"default": default, "intent": intent}
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def body(self, name: str, default: typing.Any = NO_DEFAULT, intent: str = None):
|
|
70
|
+
self._body[name] = {"default": default, "intent": intent}
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def build(self):
|
|
74
|
+
return {
|
|
75
|
+
"path": self.path,
|
|
76
|
+
"query_fields": self._query,
|
|
77
|
+
"body_fields": self._body
|
|
78
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
import typing
|
|
4
|
+
import socket
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .handlers import LogHandler
|
|
8
|
+
from .builders import TrapBuilder, WatchBuilder, NO_DEFAULT
|
|
9
|
+
|
|
10
|
+
class IdentityContext:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.ip = None
|
|
13
|
+
self.auth = None
|
|
14
|
+
|
|
15
|
+
def get_context(self, request_obj):
|
|
16
|
+
u, r = None, None
|
|
17
|
+
|
|
18
|
+
if self.auth:
|
|
19
|
+
auth_context = self.auth(request_obj)
|
|
20
|
+
if isinstance(auth_context, dict):
|
|
21
|
+
u, r = auth_context.get("user"), auth_context.get("role")
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"user": u,
|
|
25
|
+
"role": r,
|
|
26
|
+
"ip": self.ip(request_obj) if self.ip else None
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class RequestContext:
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.path = lambda r: None
|
|
32
|
+
self.user_agent = lambda r: None
|
|
33
|
+
self.method = lambda r: None
|
|
34
|
+
|
|
35
|
+
def get_context(self, request_obj):
|
|
36
|
+
return {
|
|
37
|
+
"path": self.path(request_obj),
|
|
38
|
+
"user_agent": self.user_agent(request_obj),
|
|
39
|
+
"method": self.method(request_obj)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class Sentry:
|
|
43
|
+
def __init__(self, app, service: str, environment: str):
|
|
44
|
+
self.logger = logging.getLogger("trappsec")
|
|
45
|
+
if not self.logger.handlers:
|
|
46
|
+
logging.basicConfig(format="%(message)s", level=logging.WARNING)
|
|
47
|
+
|
|
48
|
+
self.hostname = socket.gethostname()
|
|
49
|
+
self.service = service
|
|
50
|
+
self.environment = environment
|
|
51
|
+
|
|
52
|
+
self.integration = None
|
|
53
|
+
|
|
54
|
+
self.identity = IdentityContext()
|
|
55
|
+
self.request = RequestContext()
|
|
56
|
+
|
|
57
|
+
self._traps = []
|
|
58
|
+
self._watches = []
|
|
59
|
+
self._templates = {}
|
|
60
|
+
self._handlers = [LogHandler(self.logger)]
|
|
61
|
+
|
|
62
|
+
self.default_responses = {
|
|
63
|
+
"authenticated": {
|
|
64
|
+
"status_code": 200,
|
|
65
|
+
"response_body": {},
|
|
66
|
+
"mime_type": "application/json"
|
|
67
|
+
},
|
|
68
|
+
"unauthenticated": {
|
|
69
|
+
"status_code": 401,
|
|
70
|
+
"response_body": {},
|
|
71
|
+
"mime_type": "application/json"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
self._register(app)
|
|
76
|
+
|
|
77
|
+
def template(self, name: str, status_code: int, response_body: dict, mime_type: str = "application/json"):
|
|
78
|
+
self._templates[name] = {"status_code": status_code, "response_body": response_body, "mime_type": mime_type}
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def trap(self, path: str):
|
|
82
|
+
builder = TrapBuilder(self, path)
|
|
83
|
+
self._traps.append(builder)
|
|
84
|
+
return builder
|
|
85
|
+
|
|
86
|
+
def watch(self, path: str):
|
|
87
|
+
builder = WatchBuilder(path)
|
|
88
|
+
self._watches.append(builder)
|
|
89
|
+
return builder
|
|
90
|
+
|
|
91
|
+
def add_webhook(self, url: str, secret: str = None, headers: dict = None, heartbeat_interval: int = None, template: typing.Callable = None):
|
|
92
|
+
from .handlers import WebhookHandler
|
|
93
|
+
handler = WebhookHandler(
|
|
94
|
+
url=url,
|
|
95
|
+
secret=secret,
|
|
96
|
+
headers=headers,
|
|
97
|
+
service=self.service,
|
|
98
|
+
environment=self.environment,
|
|
99
|
+
heartbeat_interval=heartbeat_interval,
|
|
100
|
+
template=template
|
|
101
|
+
)
|
|
102
|
+
self._handlers.append(handler)
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def add_otel(self):
|
|
106
|
+
from .handlers import OTELHandler
|
|
107
|
+
self._handlers.append(OTELHandler())
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def identify_user(self, callback: typing.Callable):
|
|
111
|
+
self.identity.auth = callback
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def override_source_ip(self, callback: typing.Callable):
|
|
115
|
+
self.identity.ip = callback
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def traps(self):
|
|
120
|
+
return [d.build() if hasattr(d, "build") else d for d in self._traps]
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def watches(self):
|
|
124
|
+
return [r.build() if hasattr(r, "build") else r for r in self._watches]
|
|
125
|
+
|
|
126
|
+
def trigger(self, req, reason: str, intent: str = None, metadata: dict = None):
|
|
127
|
+
identity_ctx = self.identity.get_context(req)
|
|
128
|
+
request_ctx = self.request.get_context(req)
|
|
129
|
+
|
|
130
|
+
trigger_ctx = {
|
|
131
|
+
"timestamp": time.time(),
|
|
132
|
+
"event": "trappsec.rule_hit",
|
|
133
|
+
"type": "signal",
|
|
134
|
+
"reason": reason,
|
|
135
|
+
"intent": intent,
|
|
136
|
+
"path": request_ctx["path"],
|
|
137
|
+
"method": request_ctx["method"],
|
|
138
|
+
"user_agent": request_ctx["user_agent"],
|
|
139
|
+
"ip": identity_ctx["ip"],
|
|
140
|
+
"metadata": metadata,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if identity_ctx["user"]:
|
|
144
|
+
trigger_ctx["type"] = "alert"
|
|
145
|
+
trigger_ctx["user"] = identity_ctx["user"]
|
|
146
|
+
trigger_ctx["role"] = identity_ctx["role"]
|
|
147
|
+
|
|
148
|
+
self._trigger(trigger_ctx)
|
|
149
|
+
|
|
150
|
+
def _trigger(self, trigger_ctx):
|
|
151
|
+
trigger_ctx["app"] = {
|
|
152
|
+
"service": self.service,
|
|
153
|
+
"environment": self.environment,
|
|
154
|
+
"hostname": self.hostname
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for h in self._handlers:
|
|
158
|
+
try:
|
|
159
|
+
h.emit(trigger_ctx)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
self.logger.error("error invoking log handler: ", e)
|
|
162
|
+
|
|
163
|
+
def _trigger_watch_event(self, req, found_fields):
|
|
164
|
+
identity_ctx = self.identity.get_context(req)
|
|
165
|
+
request_ctx = self.request.get_context(req)
|
|
166
|
+
|
|
167
|
+
trigger_ctx = {
|
|
168
|
+
"timestamp": time.time(),
|
|
169
|
+
"event": "trappsec.watch_hit",
|
|
170
|
+
"type": "signal",
|
|
171
|
+
"path": request_ctx["path"],
|
|
172
|
+
"method": request_ctx["method"],
|
|
173
|
+
"user_agent": request_ctx["user_agent"],
|
|
174
|
+
"ip": identity_ctx["ip"],
|
|
175
|
+
"found_fields": found_fields
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if identity_ctx["user"]:
|
|
179
|
+
trigger_ctx["type"] = "alert"
|
|
180
|
+
trigger_ctx["user"] = identity_ctx["user"]
|
|
181
|
+
trigger_ctx["role"] = identity_ctx["role"]
|
|
182
|
+
|
|
183
|
+
self._trigger(trigger_ctx)
|
|
184
|
+
|
|
185
|
+
def _trigger_trap_event(self, req, trap):
|
|
186
|
+
identity_ctx = self.identity.get_context(req)
|
|
187
|
+
request_ctx = self.request.get_context(req)
|
|
188
|
+
|
|
189
|
+
trigger_ctx = {
|
|
190
|
+
"timestamp": time.time(),
|
|
191
|
+
"event": "trappsec.trap_hit",
|
|
192
|
+
"type": "signal",
|
|
193
|
+
"path": request_ctx["path"],
|
|
194
|
+
"method": request_ctx["method"],
|
|
195
|
+
"user_agent": request_ctx["user_agent"],
|
|
196
|
+
"ip": identity_ctx["ip"],
|
|
197
|
+
"intent": trap.get("intent"),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
response_key = "response.unauthenticated"
|
|
201
|
+
|
|
202
|
+
if identity_ctx["user"]:
|
|
203
|
+
trigger_ctx["type"] = "alert"
|
|
204
|
+
trigger_ctx["user"] = identity_ctx["user"]
|
|
205
|
+
trigger_ctx["role"] = identity_ctx["role"]
|
|
206
|
+
response_key = "response.authenticated"
|
|
207
|
+
|
|
208
|
+
self._trigger(trigger_ctx)
|
|
209
|
+
|
|
210
|
+
response_config = trap[response_key]
|
|
211
|
+
response_body = response_config["response_body"]
|
|
212
|
+
|
|
213
|
+
if callable(response_body):
|
|
214
|
+
response_body = response_body(req)
|
|
215
|
+
|
|
216
|
+
if response_config["mime_type"] == "application/json":
|
|
217
|
+
response_body = json.dumps(response_body)
|
|
218
|
+
|
|
219
|
+
return response_body, response_config
|
|
220
|
+
|
|
221
|
+
def _detect_honey_fields(self, data, rules, request_obj=None):
|
|
222
|
+
found_fields = []
|
|
223
|
+
|
|
224
|
+
for key in list(data.keys()):
|
|
225
|
+
if key in rules:
|
|
226
|
+
rule_definition = rules[key]
|
|
227
|
+
expected = rule_definition.get("default", NO_DEFAULT)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
if callable(expected):
|
|
231
|
+
expected = expected(request_obj)
|
|
232
|
+
|
|
233
|
+
if expected is NO_DEFAULT or data[key] != expected:
|
|
234
|
+
found_fields.append({
|
|
235
|
+
"type": "body",
|
|
236
|
+
"field": key,
|
|
237
|
+
"value": data[key],
|
|
238
|
+
"intent": rule_definition.get("intent", None),
|
|
239
|
+
})
|
|
240
|
+
except Exception as e:
|
|
241
|
+
self.logger.error(f"failed to evaluate callable expected value for body field `{key}`: ", e)
|
|
242
|
+
|
|
243
|
+
del data[key]
|
|
244
|
+
return data, found_fields
|
|
245
|
+
|
|
246
|
+
def _register(self, app):
|
|
247
|
+
name = app.__class__.__name__
|
|
248
|
+
module = app.__class__.__module__
|
|
249
|
+
|
|
250
|
+
if name == "FastAPI" or module.startswith("fastapi"):
|
|
251
|
+
from .integrations.fastapi import FastAPIIntegration
|
|
252
|
+
self.integration = FastAPIIntegration(self, app)
|
|
253
|
+
elif name == "Flask" or module.startswith("flask"):
|
|
254
|
+
from .integrations.flask import FlaskIntegration
|
|
255
|
+
self.integration = FlaskIntegration(self, app)
|
|
256
|
+
else:
|
|
257
|
+
raise Exception("trappsec error: unknown framework.")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import json
|
|
3
|
+
import hmac
|
|
4
|
+
import hashlib
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import requests
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
12
|
+
except ImportError:
|
|
13
|
+
requests = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseHandler:
|
|
17
|
+
def emit(self, event: dict): raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
class LogHandler(BaseHandler):
|
|
20
|
+
def __init__(self, logger: logging.Logger):
|
|
21
|
+
self.logger = logger
|
|
22
|
+
def emit(self, event: dict):
|
|
23
|
+
self.logger.warning(json.dumps(event))
|
|
24
|
+
|
|
25
|
+
class WebhookHandler(BaseHandler):
|
|
26
|
+
def __init__(self, url: str, secret: str = None, headers: dict = None, service: str = None, environment: str = None, heartbeat_interval: int = None, template: callable = None):
|
|
27
|
+
if requests is None:
|
|
28
|
+
raise ImportError("requests library required for WebhookHandler")
|
|
29
|
+
|
|
30
|
+
self.url = url
|
|
31
|
+
self.secret = secret
|
|
32
|
+
self.service = service
|
|
33
|
+
self.environment = environment
|
|
34
|
+
self.template = template
|
|
35
|
+
self.logger = logging.getLogger("trappsec")
|
|
36
|
+
|
|
37
|
+
self.headers = {"Content-Type": "application/json"}
|
|
38
|
+
self.headers.update(headers or {})
|
|
39
|
+
|
|
40
|
+
self.session = requests.Session()
|
|
41
|
+
self.session.mount("https://", HTTPAdapter(max_retries=Retry(total=3, backoff_factor=1)))
|
|
42
|
+
|
|
43
|
+
if heartbeat_interval:
|
|
44
|
+
threading.Thread(target=self._heartbeat_loop, args=(heartbeat_interval,), daemon=True).start()
|
|
45
|
+
|
|
46
|
+
def emit(self, event: dict):
|
|
47
|
+
if self.template:
|
|
48
|
+
try:
|
|
49
|
+
event = self.template(event)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
self.logger.error(f"Failed to apply webhook template: {e}")
|
|
52
|
+
|
|
53
|
+
payload = json.dumps(event)
|
|
54
|
+
self._send(payload)
|
|
55
|
+
|
|
56
|
+
def _heartbeat_loop(self, interval: int):
|
|
57
|
+
while True:
|
|
58
|
+
time.sleep(interval)
|
|
59
|
+
payload = json.dumps({
|
|
60
|
+
"timestamp": time.time(),
|
|
61
|
+
"event": "trappsec.heartbeat",
|
|
62
|
+
"service": self.service,
|
|
63
|
+
"environment": self.environment,
|
|
64
|
+
})
|
|
65
|
+
self._send(payload)
|
|
66
|
+
|
|
67
|
+
def _send(self, payload: str):
|
|
68
|
+
headers = self.headers.copy()
|
|
69
|
+
if self.secret:
|
|
70
|
+
headers["x-trappsec-signature"] = hmac.new(
|
|
71
|
+
self.secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
self.session.post(self.url, data=payload, headers=headers, timeout=5)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
self.logger.error(f"Failed to send webhook: {e}")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
from opentelemetry import trace
|
|
80
|
+
except ImportError:
|
|
81
|
+
trace = None
|
|
82
|
+
|
|
83
|
+
class OTELHandler(BaseHandler):
|
|
84
|
+
def __init__(self):
|
|
85
|
+
if trace is None:
|
|
86
|
+
raise ImportError("opentelemetry-api library required for OTELHandler")
|
|
87
|
+
|
|
88
|
+
def emit(self, event: dict):
|
|
89
|
+
current_span = trace.get_current_span()
|
|
90
|
+
if current_span.is_recording():
|
|
91
|
+
current_span.set_attribute("trappsec.detected", True)
|
|
92
|
+
current_span.set_attribute("trappsec.event", event["event"])
|
|
93
|
+
current_span.set_attribute("trappsec.type", event["type"])
|
|
94
|
+
|
|
95
|
+
if event.get("user"):
|
|
96
|
+
current_span.set_attribute("trappsec.user", event["user"])
|
|
97
|
+
if event.get("role"):
|
|
98
|
+
current_span.set_attribute("trappsec.role", event["role"])
|
|
99
|
+
|
|
100
|
+
if event.get("ip"):
|
|
101
|
+
current_span.set_attribute("trappsec.ip", event["ip"])
|
|
102
|
+
|
|
103
|
+
if event["event"] == "trappsec.watch_hit":
|
|
104
|
+
for field_info in event["found_fields"]:
|
|
105
|
+
current_span.add_event("watch_hit", field_info)
|
|
106
|
+
|
|
107
|
+
if event["event"] == "trappsec.trap_hit":
|
|
108
|
+
if event.get("intent"):
|
|
109
|
+
current_span.set_attribute("trappsec.intent", event["intent"])
|
|
110
|
+
|
|
111
|
+
if event["event"] == "trappsec.rule_hit":
|
|
112
|
+
if event.get("intent"):
|
|
113
|
+
current_span.set_attribute("trappsec.intent", event["intent"])
|
|
114
|
+
if event.get("reason"):
|
|
115
|
+
current_span.set_attribute("trappsec.reason", event["reason"])
|
|
116
|
+
|
|
117
|
+
if event.get("metadata"):
|
|
118
|
+
metadata = event["metadata"]
|
|
119
|
+
if isinstance(metadata, dict):
|
|
120
|
+
attrs = {f"metadata.{k}": v for k, v in metadata.items()}
|
|
121
|
+
current_span.set_attributes(attrs)
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
|
|
3
|
+
class FastAPIIntegration:
|
|
4
|
+
def __init__(self, ts, app):
|
|
5
|
+
self.ts = ts
|
|
6
|
+
self.app = app
|
|
7
|
+
self.watch_map = None
|
|
8
|
+
|
|
9
|
+
if not self.ts.identity.ip:
|
|
10
|
+
self.ts.identity.ip = lambda r: r.client.host if r.client else "0.0.0.0"
|
|
11
|
+
|
|
12
|
+
self.ts.request.path = lambda r: str(r.url.path)
|
|
13
|
+
self.ts.request.user_agent = lambda r: r.headers.get("user-agent", "unknown")
|
|
14
|
+
self.ts.request.method = lambda r: r.method
|
|
15
|
+
|
|
16
|
+
self.setup_middleware()
|
|
17
|
+
self._patch_startup()
|
|
18
|
+
|
|
19
|
+
def inject_traps(self):
|
|
20
|
+
from fastapi import Request, Response
|
|
21
|
+
from fastapi.routing import APIRoute
|
|
22
|
+
|
|
23
|
+
async def endpoint(req: Request, trap):
|
|
24
|
+
response_body, response_config = self.ts._trigger_trap_event(req, trap)
|
|
25
|
+
|
|
26
|
+
return Response(response_body,
|
|
27
|
+
status_code=response_config["status_code"],
|
|
28
|
+
media_type=response_config["mime_type"])
|
|
29
|
+
|
|
30
|
+
new_routes = []
|
|
31
|
+
for idx, trap in enumerate(self.ts.traps):
|
|
32
|
+
new_routes.append(APIRoute(trap["path"], partial(endpoint, trap=trap),
|
|
33
|
+
methods=trap["methods"], name=f"trappsec_{idx}", include_in_schema=False))
|
|
34
|
+
|
|
35
|
+
self.app.router.routes = new_routes + self.app.router.routes
|
|
36
|
+
|
|
37
|
+
def setup_watches(self):
|
|
38
|
+
watch_map = dict()
|
|
39
|
+
for watch in self.ts.watches:
|
|
40
|
+
watch_map[watch["path"]] = watch
|
|
41
|
+
self.watch_map = watch_map
|
|
42
|
+
|
|
43
|
+
def setup_middleware(self):
|
|
44
|
+
from fastapi import Request, Depends
|
|
45
|
+
from starlette.datastructures import FormData
|
|
46
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
47
|
+
from urllib.parse import parse_qs, urlencode
|
|
48
|
+
|
|
49
|
+
async def trappsec_watcher(request: Request):
|
|
50
|
+
route = request.scope.get("route")
|
|
51
|
+
if route is None:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if route.path not in self.watch_map:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
matched_rule = self.watch_map[route.path]
|
|
58
|
+
query_fields = matched_rule["query_fields"]
|
|
59
|
+
body_fields = matched_rule["body_fields"]
|
|
60
|
+
found_fields = []
|
|
61
|
+
|
|
62
|
+
if query_fields:
|
|
63
|
+
qs = request.scope.get("query_string", b"").decode("utf-8")
|
|
64
|
+
if qs:
|
|
65
|
+
q_dict = parse_qs(qs).to_dict(flat=False)
|
|
66
|
+
q_dict, mod = self.ts._detect_honey_fields(q_dict, query_fields, request)
|
|
67
|
+
|
|
68
|
+
if mod:
|
|
69
|
+
found_fields.extend(mod)
|
|
70
|
+
request.scope["query_string"] = urlencode(q_dict, doseq=True).encode("utf-8")
|
|
71
|
+
if hasattr(request, "_query_params"):
|
|
72
|
+
del request._query_params
|
|
73
|
+
|
|
74
|
+
if body_fields:
|
|
75
|
+
ctype = request.headers.get("content-type", "")
|
|
76
|
+
if "application/json" in ctype:
|
|
77
|
+
try:
|
|
78
|
+
body = await request.json()
|
|
79
|
+
b, mod = self.ts._detect_honey_fields(body, body_fields, request)
|
|
80
|
+
if mod:
|
|
81
|
+
found_fields.extend(mod)
|
|
82
|
+
request._json = b
|
|
83
|
+
except Exception as e:
|
|
84
|
+
self.ts.logger.error("error reading json body: %s", e)
|
|
85
|
+
elif "application/x-www-form-urlencoded" in ctype or "multipart/form-data" in ctype:
|
|
86
|
+
try:
|
|
87
|
+
form_data = await request.form()
|
|
88
|
+
data = dict(form_data)
|
|
89
|
+
b, mod = self.ts._detect_honey_fields(data, body_fields, request)
|
|
90
|
+
if mod:
|
|
91
|
+
found_fields.extend(mod)
|
|
92
|
+
request._form = FormData(b)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
self.ts.logger.error("error reading form body: %s", e)
|
|
95
|
+
|
|
96
|
+
if found_fields:
|
|
97
|
+
self.ts._trigger_watch_event(request, found_fields)
|
|
98
|
+
|
|
99
|
+
if self.app.router.dependencies is None:
|
|
100
|
+
self.app.router.dependencies = []
|
|
101
|
+
|
|
102
|
+
self.app.router.dependencies.append(Depends(trappsec_watcher))
|
|
103
|
+
|
|
104
|
+
def _patch_startup(self):
|
|
105
|
+
from contextlib import asynccontextmanager
|
|
106
|
+
original_lifespan = self.app.router.lifespan_context
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def wrapped_lifespan(app_instance):
|
|
110
|
+
self.inject_traps()
|
|
111
|
+
self.setup_watches()
|
|
112
|
+
|
|
113
|
+
async with original_lifespan(app_instance) as state:
|
|
114
|
+
yield state
|
|
115
|
+
|
|
116
|
+
self.app.router.lifespan_context = wrapped_lifespan
|
|
117
|
+
|
|
118
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
|
|
2
|
+
class FlaskIntegration:
|
|
3
|
+
def __init__(self, ts, app):
|
|
4
|
+
self.ts = ts
|
|
5
|
+
self.app = app
|
|
6
|
+
|
|
7
|
+
if not self.ts.identity.ip:
|
|
8
|
+
self.ts.identity.ip = lambda r: r.remote_addr or "0.0.0.0"
|
|
9
|
+
|
|
10
|
+
self.ts.request.path = lambda r: r.path
|
|
11
|
+
self.ts.request.user_agent = lambda r: str(r.user_agent)
|
|
12
|
+
self.ts.request.method = lambda r: r.method
|
|
13
|
+
|
|
14
|
+
self._patch_startup()
|
|
15
|
+
|
|
16
|
+
def inject_traps(self):
|
|
17
|
+
from flask import request, Response
|
|
18
|
+
|
|
19
|
+
for idx, decoy in enumerate(self.ts.traps):
|
|
20
|
+
def endpoint(d=decoy):
|
|
21
|
+
response_body, response_config = self.ts._trigger_trap_event(request, d)
|
|
22
|
+
|
|
23
|
+
return Response(
|
|
24
|
+
response_body,
|
|
25
|
+
status=response_config["status_code"],
|
|
26
|
+
mimetype=response_config["mime_type"])
|
|
27
|
+
|
|
28
|
+
endpoint.__name__ = f"trappsec_{idx}"
|
|
29
|
+
self.app.add_url_rule(decoy["path"], endpoint.__name__, endpoint, methods=decoy["methods"])
|
|
30
|
+
|
|
31
|
+
def setup_watches(self):
|
|
32
|
+
if not self.ts.watches:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
from flask import request
|
|
36
|
+
from werkzeug.datastructures import ImmutableMultiDict
|
|
37
|
+
|
|
38
|
+
watch_map = dict()
|
|
39
|
+
for watch in self.ts.watches:
|
|
40
|
+
watch_map[watch["path"]] = watch
|
|
41
|
+
|
|
42
|
+
@self.app.before_request
|
|
43
|
+
def trappsec_watcher():
|
|
44
|
+
if request.url_rule is None:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
matched_rule = request.url_rule.rule
|
|
48
|
+
if matched_rule not in watch_map:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
query_fields = watch_map[matched_rule]["query_fields"]
|
|
52
|
+
body_fields = watch_map[matched_rule]["body_fields"]
|
|
53
|
+
|
|
54
|
+
found_fields = []
|
|
55
|
+
if request.args and query_fields:
|
|
56
|
+
q_dict = request.args.to_dict(flat=False)
|
|
57
|
+
q_dict, mod = self.ts._detect_honey_fields(q_dict, query_fields, request)
|
|
58
|
+
if mod:
|
|
59
|
+
found_fields.extend(mod)
|
|
60
|
+
request.args = ImmutableMultiDict(q_dict)
|
|
61
|
+
|
|
62
|
+
if request.is_json and body_fields:
|
|
63
|
+
data = request.get_json(silent=True)
|
|
64
|
+
if data:
|
|
65
|
+
data, mod = self.ts._detect_honey_fields(data, body_fields, request)
|
|
66
|
+
if mod:
|
|
67
|
+
found_fields.extend(mod)
|
|
68
|
+
current = getattr(request, '_cached_json', None)
|
|
69
|
+
if isinstance(current, tuple):
|
|
70
|
+
request._cached_json = (data, current[1])
|
|
71
|
+
else:
|
|
72
|
+
request._cached_json = data
|
|
73
|
+
|
|
74
|
+
if request.form and body_fields:
|
|
75
|
+
form_copy = request.form.to_dict(flat=True)
|
|
76
|
+
form, mod = self.ts._detect_honey_fields(form_copy, body_fields, request)
|
|
77
|
+
if mod:
|
|
78
|
+
found_fields.extend(mod)
|
|
79
|
+
request.form = ImmutableMultiDict(form)
|
|
80
|
+
|
|
81
|
+
if found_fields:
|
|
82
|
+
self.ts._trigger_watch_event(request, found_fields)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _patch_startup(self):
|
|
86
|
+
original_wsgi_app = self.app.wsgi_app
|
|
87
|
+
|
|
88
|
+
def trappsec_wrapper(environ, start_response):
|
|
89
|
+
self.inject_traps()
|
|
90
|
+
self.setup_watches()
|
|
91
|
+
|
|
92
|
+
# un-patch after first request
|
|
93
|
+
self.app.wsgi_app = original_wsgi_app
|
|
94
|
+
|
|
95
|
+
return original_wsgi_app(environ, start_response)
|
|
96
|
+
|
|
97
|
+
self.app.wsgi_app = trappsec_wrapper
|
|
98
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trappsec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: deception as a developer tool
|
|
5
|
+
Author-email: Nikhil Salgaonkar <nikhil@ftfy.co>
|
|
6
|
+
Project-URL: Homepage, https://trappsec.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/trappsec-dev/trappsec
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/trappsec-dev/trappsec/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: webhooks
|
|
15
|
+
Requires-Dist: requests; extra == "webhooks"
|
|
16
|
+
Provides-Extra: otel
|
|
17
|
+
Requires-Dist: opentelemetry-api; extra == "otel"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/trappsec/__init__.py
|
|
3
|
+
src/trappsec/builders.py
|
|
4
|
+
src/trappsec/core.py
|
|
5
|
+
src/trappsec/handlers.py
|
|
6
|
+
src/trappsec.egg-info/PKG-INFO
|
|
7
|
+
src/trappsec.egg-info/SOURCES.txt
|
|
8
|
+
src/trappsec.egg-info/dependency_links.txt
|
|
9
|
+
src/trappsec.egg-info/requires.txt
|
|
10
|
+
src/trappsec.egg-info/top_level.txt
|
|
11
|
+
src/trappsec/integrations/__init__.py
|
|
12
|
+
src/trappsec/integrations/fastapi.py
|
|
13
|
+
src/trappsec/integrations/flask.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trappsec
|