farai 0.1.0
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.
- package/LICENSE +165 -0
- package/README.md +147 -0
- package/dist/cli/index.js +33048 -0
- package/docker/kali/Dockerfile +117 -0
- package/docker/kali/farai-image-doctor.py +83 -0
- package/docker/kali/farai-mitmproxy-mcp.sh +9 -0
- package/docker/kali/farai-proxy-init.sh +43 -0
- package/docker/kali/farai-proxy-teardown.sh +17 -0
- package/docker/kali/farai-tool-manifest.json +215 -0
- package/docker/kali/farai_mitmproxy_mcp.py +562 -0
- package/docker/kali/redsocks.conf.tmpl +15 -0
- package/package.json +71 -0
- package/src/agent-skills/library/ffuf/SKILL.md +23 -0
- package/src/agent-skills/library/nmap/SKILL.md +30 -0
- package/src/agent-skills/library/payload-protocol-crafting/SKILL.md +26 -0
- package/src/agent-skills/library/reverse-shells/SKILL.md +55 -0
- package/src/agent-skills/library/searchsploit/SKILL.md +18 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Farai's multi-protocol recorder extension for mitmproxy-mcp 0.6.1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mitmproxy import dns, http, tcp, udp
|
|
14
|
+
from mitmproxy_mcp.core import server
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
UPSTREAM_TRAFFIC_RECORDER = server.TrafficRecorder
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
|
21
|
+
try:
|
|
22
|
+
value = int(os.environ.get(name, str(default)))
|
|
23
|
+
except ValueError:
|
|
24
|
+
value = default
|
|
25
|
+
return max(minimum, min(value, maximum))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MAX_BODY_PREVIEW = env_int("FARAI_PROXY_BODY_PREVIEW_BYTES", 65536, 1024, 1024 * 1024)
|
|
29
|
+
MAX_MESSAGE_PREVIEW = env_int("FARAI_PROXY_MESSAGE_PREVIEW_BYTES", 16384, 1024, 256 * 1024)
|
|
30
|
+
MAX_MESSAGES = env_int("FARAI_PROXY_MAX_MESSAGES", 200, 1, 1000)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FlowStoreV2:
|
|
34
|
+
def __init__(self, db_path: str):
|
|
35
|
+
self.db_path = db_path
|
|
36
|
+
with self._connect() as conn:
|
|
37
|
+
conn.execute(
|
|
38
|
+
"""
|
|
39
|
+
CREATE TABLE IF NOT EXISTS farai_flows_v2 (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
kind TEXT NOT NULL,
|
|
42
|
+
timestamp REAL NOT NULL,
|
|
43
|
+
summary_json TEXT NOT NULL,
|
|
44
|
+
detail_json TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
conn.execute(
|
|
49
|
+
"CREATE INDEX IF NOT EXISTS idx_farai_flows_v2_timestamp "
|
|
50
|
+
"ON farai_flows_v2(timestamp)"
|
|
51
|
+
)
|
|
52
|
+
conn.execute(
|
|
53
|
+
"CREATE INDEX IF NOT EXISTS idx_farai_flows_v2_kind "
|
|
54
|
+
"ON farai_flows_v2(kind)"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _connect(self) -> sqlite3.Connection:
|
|
58
|
+
conn = sqlite3.connect(self.db_path, timeout=5, check_same_thread=False)
|
|
59
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
60
|
+
return conn
|
|
61
|
+
|
|
62
|
+
def save(self, summary: dict[str, Any], detail: dict[str, Any], timestamp: float) -> None:
|
|
63
|
+
encoded_summary = json.dumps(summary, separators=(",", ":"), ensure_ascii=True)
|
|
64
|
+
encoded_detail = json.dumps(detail, separators=(",", ":"), ensure_ascii=True)
|
|
65
|
+
with self._connect() as conn:
|
|
66
|
+
conn.execute(
|
|
67
|
+
"""
|
|
68
|
+
INSERT INTO farai_flows_v2(id, kind, timestamp, summary_json, detail_json)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?)
|
|
70
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
71
|
+
kind=excluded.kind,
|
|
72
|
+
timestamp=excluded.timestamp,
|
|
73
|
+
summary_json=excluded.summary_json,
|
|
74
|
+
detail_json=excluded.detail_json
|
|
75
|
+
""",
|
|
76
|
+
(summary["id"], summary["kind"], timestamp, encoded_summary, encoded_detail),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def summaries(self, limit: int, kind: str | None = None) -> list[dict[str, Any]]:
|
|
80
|
+
bounded_limit = max(1, min(int(limit), 1000))
|
|
81
|
+
sql = "SELECT summary_json FROM farai_flows_v2"
|
|
82
|
+
params: list[Any] = []
|
|
83
|
+
if kind:
|
|
84
|
+
sql += " WHERE kind = ?"
|
|
85
|
+
params.append(kind)
|
|
86
|
+
sql += " ORDER BY timestamp DESC LIMIT ?"
|
|
87
|
+
params.append(bounded_limit)
|
|
88
|
+
with self._connect() as conn:
|
|
89
|
+
rows = conn.execute(sql, params).fetchall()
|
|
90
|
+
return [json.loads(row[0]) for row in rows]
|
|
91
|
+
|
|
92
|
+
def detail(self, flow_id: str) -> dict[str, Any] | None:
|
|
93
|
+
with self._connect() as conn:
|
|
94
|
+
row = conn.execute(
|
|
95
|
+
"SELECT detail_json FROM farai_flows_v2 WHERE id = ?", (flow_id,)
|
|
96
|
+
).fetchone()
|
|
97
|
+
return json.loads(row[0]) if row else None
|
|
98
|
+
|
|
99
|
+
def clear(self) -> None:
|
|
100
|
+
with self._connect() as conn:
|
|
101
|
+
conn.execute("DELETE FROM farai_flows_v2")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FaraiTrafficRecorder(UPSTREAM_TRAFFIC_RECORDER):
|
|
105
|
+
def __init__(self, scope: Any):
|
|
106
|
+
super().__init__(scope)
|
|
107
|
+
self.v2 = FlowStoreV2(self.db.db_path)
|
|
108
|
+
|
|
109
|
+
def request(self, flow: http.HTTPFlow) -> None:
|
|
110
|
+
super().request(flow)
|
|
111
|
+
self._capture(flow)
|
|
112
|
+
|
|
113
|
+
def response(self, flow: http.HTTPFlow) -> None:
|
|
114
|
+
super().response(flow)
|
|
115
|
+
self._capture(flow)
|
|
116
|
+
|
|
117
|
+
def error(self, flow: http.HTTPFlow) -> None:
|
|
118
|
+
super().error(flow)
|
|
119
|
+
self._capture(flow)
|
|
120
|
+
|
|
121
|
+
def websocket_start(self, flow: http.HTTPFlow) -> None:
|
|
122
|
+
self._capture(flow)
|
|
123
|
+
|
|
124
|
+
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
|
125
|
+
self._capture(flow)
|
|
126
|
+
|
|
127
|
+
def websocket_end(self, flow: http.HTTPFlow) -> None:
|
|
128
|
+
self._capture(flow)
|
|
129
|
+
|
|
130
|
+
def tcp_start(self, flow: tcp.TCPFlow) -> None:
|
|
131
|
+
self._capture(flow)
|
|
132
|
+
|
|
133
|
+
def tcp_message(self, flow: tcp.TCPFlow) -> None:
|
|
134
|
+
self._capture(flow)
|
|
135
|
+
|
|
136
|
+
def tcp_end(self, flow: tcp.TCPFlow) -> None:
|
|
137
|
+
self._capture(flow)
|
|
138
|
+
|
|
139
|
+
def tcp_error(self, flow: tcp.TCPFlow) -> None:
|
|
140
|
+
self._capture(flow)
|
|
141
|
+
|
|
142
|
+
def udp_start(self, flow: udp.UDPFlow) -> None:
|
|
143
|
+
self._capture(flow)
|
|
144
|
+
|
|
145
|
+
def udp_message(self, flow: udp.UDPFlow) -> None:
|
|
146
|
+
self._capture(flow)
|
|
147
|
+
|
|
148
|
+
def udp_end(self, flow: udp.UDPFlow) -> None:
|
|
149
|
+
self._capture(flow)
|
|
150
|
+
|
|
151
|
+
def udp_error(self, flow: udp.UDPFlow) -> None:
|
|
152
|
+
self._capture(flow)
|
|
153
|
+
|
|
154
|
+
def dns_request(self, flow: dns.DNSFlow) -> None:
|
|
155
|
+
self._capture(flow)
|
|
156
|
+
|
|
157
|
+
def dns_response(self, flow: dns.DNSFlow) -> None:
|
|
158
|
+
self._capture(flow)
|
|
159
|
+
|
|
160
|
+
def dns_error(self, flow: dns.DNSFlow) -> None:
|
|
161
|
+
self._capture(flow)
|
|
162
|
+
|
|
163
|
+
def get_flow_summary_v2(self, limit: int = 20, kind: str | None = None) -> list[dict[str, Any]]:
|
|
164
|
+
return self.v2.summaries(limit, kind)
|
|
165
|
+
|
|
166
|
+
def get_flow_detail_v2(self, flow_id: str) -> dict[str, Any] | None:
|
|
167
|
+
return self.v2.detail(flow_id)
|
|
168
|
+
|
|
169
|
+
def clear(self) -> None:
|
|
170
|
+
super().clear()
|
|
171
|
+
self.v2.clear()
|
|
172
|
+
|
|
173
|
+
def _capture(self, flow: Any) -> None:
|
|
174
|
+
try:
|
|
175
|
+
if not self._is_allowed(flow):
|
|
176
|
+
return
|
|
177
|
+
summary, detail, timestamp = serialize_flow(flow)
|
|
178
|
+
self.v2.save(summary, detail, timestamp)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
print(f"Failed to save Farai v2 flow: {exc}", file=sys.stderr)
|
|
181
|
+
|
|
182
|
+
def _is_allowed(self, flow: Any) -> bool:
|
|
183
|
+
if isinstance(flow, http.HTTPFlow):
|
|
184
|
+
return self.scope.is_allowed(flow)
|
|
185
|
+
allowed_domains = self.scope.config.allowed_domains
|
|
186
|
+
if not allowed_domains:
|
|
187
|
+
return True
|
|
188
|
+
candidates = endpoint_hosts(flow)
|
|
189
|
+
if isinstance(flow, dns.DNSFlow) and flow.request:
|
|
190
|
+
candidates.extend(str(question.name) for question in flow.request.questions)
|
|
191
|
+
return any(domain in candidate for domain in allowed_domains for candidate in candidates)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def serialize_flow(flow: Any) -> tuple[dict[str, Any], dict[str, Any], float]:
|
|
195
|
+
if isinstance(flow, http.HTTPFlow):
|
|
196
|
+
if flow.websocket is not None:
|
|
197
|
+
return serialize_websocket(flow)
|
|
198
|
+
return serialize_http(flow)
|
|
199
|
+
if isinstance(flow, tcp.TCPFlow):
|
|
200
|
+
return serialize_stream(flow, "tcp")
|
|
201
|
+
if isinstance(flow, udp.UDPFlow):
|
|
202
|
+
return serialize_stream(flow, "udp")
|
|
203
|
+
if isinstance(flow, dns.DNSFlow):
|
|
204
|
+
return serialize_dns(flow)
|
|
205
|
+
raise TypeError(f"Unsupported mitmproxy flow: {type(flow).__name__}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def serialize_http(flow: http.HTTPFlow) -> tuple[dict[str, Any], dict[str, Any], float]:
|
|
209
|
+
request = flow.request
|
|
210
|
+
response = flow.response
|
|
211
|
+
timestamp = float(request.timestamp_start or flow.timestamp_start)
|
|
212
|
+
summary = base_http_summary(flow, "http", timestamp)
|
|
213
|
+
detail = {
|
|
214
|
+
**summary,
|
|
215
|
+
"request": http_message(request),
|
|
216
|
+
}
|
|
217
|
+
if response:
|
|
218
|
+
detail["response"] = {
|
|
219
|
+
**http_message(response),
|
|
220
|
+
"status": response.status_code,
|
|
221
|
+
"reason": response.reason,
|
|
222
|
+
}
|
|
223
|
+
return summary, detail, timestamp
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def serialize_websocket(flow: http.HTTPFlow) -> tuple[dict[str, Any], dict[str, Any], float]:
|
|
227
|
+
request = flow.request
|
|
228
|
+
response = flow.response
|
|
229
|
+
websocket = flow.websocket
|
|
230
|
+
timestamp = float(request.timestamp_start or flow.timestamp_start)
|
|
231
|
+
messages = [stream_message(message, websocket=True) for message in websocket.messages[-MAX_MESSAGES:]]
|
|
232
|
+
summary = {
|
|
233
|
+
**base_http_summary(flow, "websocket", timestamp),
|
|
234
|
+
"method": "WS",
|
|
235
|
+
"messageCount": len(websocket.messages),
|
|
236
|
+
}
|
|
237
|
+
optional(summary, "closeCode", websocket.close_code)
|
|
238
|
+
optional(summary, "closeReason", websocket.close_reason)
|
|
239
|
+
optional(summary, "closedByClient", websocket.closed_by_client)
|
|
240
|
+
detail = {
|
|
241
|
+
**summary,
|
|
242
|
+
"handshake": {
|
|
243
|
+
"request": http_message(request),
|
|
244
|
+
},
|
|
245
|
+
"messages": messages,
|
|
246
|
+
}
|
|
247
|
+
if response:
|
|
248
|
+
detail["handshake"]["response"] = {
|
|
249
|
+
**http_message(response),
|
|
250
|
+
"status": response.status_code,
|
|
251
|
+
"reason": response.reason,
|
|
252
|
+
}
|
|
253
|
+
return summary, detail, timestamp
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def base_http_summary(flow: http.HTTPFlow, kind: str, timestamp: float) -> dict[str, Any]:
|
|
257
|
+
request = flow.request
|
|
258
|
+
response = flow.response
|
|
259
|
+
display_host = http_display_host(request)
|
|
260
|
+
summary: dict[str, Any] = {
|
|
261
|
+
"id": flow.id,
|
|
262
|
+
"kind": kind,
|
|
263
|
+
"timestamp": iso_timestamp(timestamp),
|
|
264
|
+
"method": request.method,
|
|
265
|
+
"url": request.pretty_url,
|
|
266
|
+
"host": display_host,
|
|
267
|
+
"path": request.path,
|
|
268
|
+
"requestBytes": content_size(request),
|
|
269
|
+
}
|
|
270
|
+
if response:
|
|
271
|
+
summary["status"] = response.status_code
|
|
272
|
+
summary["responseBytes"] = content_size(response)
|
|
273
|
+
content_type = response.headers.get("content-type")
|
|
274
|
+
optional(summary, "contentType", content_type)
|
|
275
|
+
if response.timestamp_end and request.timestamp_start:
|
|
276
|
+
summary["durationMs"] = max(0, round((response.timestamp_end - request.timestamp_start) * 1000))
|
|
277
|
+
optional(summary, "error", flow_error(flow))
|
|
278
|
+
return summary
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def http_display_host(request: Any) -> str:
|
|
282
|
+
pretty_host = getattr(request, "pretty_host", None)
|
|
283
|
+
if pretty_host:
|
|
284
|
+
return text_value(pretty_host)
|
|
285
|
+
host_header = getattr(request, "host_header", None)
|
|
286
|
+
if not host_header:
|
|
287
|
+
headers = getattr(request, "headers", None)
|
|
288
|
+
host_header = headers.get(":authority") if headers else None
|
|
289
|
+
host_header = host_header or (headers.get("host") if headers else None)
|
|
290
|
+
if host_header:
|
|
291
|
+
authority = text_value(host_header)
|
|
292
|
+
if authority.startswith("["):
|
|
293
|
+
return authority[1:authority.find("]")] if "]" in authority else authority
|
|
294
|
+
return authority.rsplit(":", 1)[0] if authority.count(":") == 1 else authority
|
|
295
|
+
return text_value(getattr(request, "host", "unknown"))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def serialize_stream(flow: tcp.TCPFlow | udp.UDPFlow, kind: str) -> tuple[dict[str, Any], dict[str, Any], float]:
|
|
299
|
+
timestamp = float(flow.timestamp_start or flow.timestamp_created)
|
|
300
|
+
client = endpoint(flow.client_conn, client=True)
|
|
301
|
+
server_endpoint = endpoint(flow.server_conn, client=False)
|
|
302
|
+
host = server_endpoint.get("host", "unknown")
|
|
303
|
+
messages = [stream_message(message) for message in flow.messages[-MAX_MESSAGES:]]
|
|
304
|
+
summary: dict[str, Any] = {
|
|
305
|
+
"id": flow.id,
|
|
306
|
+
"kind": kind,
|
|
307
|
+
"timestamp": iso_timestamp(timestamp),
|
|
308
|
+
"method": kind.upper(),
|
|
309
|
+
"url": f"{kind}://{server_endpoint.get('label', host)}",
|
|
310
|
+
"host": host,
|
|
311
|
+
"path": f"{client.get('label', 'client')} -> {server_endpoint.get('label', host)}",
|
|
312
|
+
"messageCount": len(flow.messages),
|
|
313
|
+
"requestBytes": sum(byte_size(message.content) for message in flow.messages if message.from_client),
|
|
314
|
+
"responseBytes": sum(byte_size(message.content) for message in flow.messages if not message.from_client),
|
|
315
|
+
}
|
|
316
|
+
if kind == "tcp":
|
|
317
|
+
summary["tls"] = bool(getattr(flow.server_conn, "tls_established", False))
|
|
318
|
+
else:
|
|
319
|
+
summary["dtls"] = bool(getattr(flow.server_conn, "tls_established", False))
|
|
320
|
+
optional(summary, "error", flow_error(flow))
|
|
321
|
+
detail = {**summary, "client": client, "server": server_endpoint, "messages": messages}
|
|
322
|
+
return summary, detail, timestamp
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def serialize_dns(flow: dns.DNSFlow) -> tuple[dict[str, Any], dict[str, Any], float]:
|
|
326
|
+
timestamp = float(flow.timestamp_start or flow.timestamp_created)
|
|
327
|
+
request = dns_message(flow.request) if flow.request else None
|
|
328
|
+
response = dns_message(flow.response) if flow.response else None
|
|
329
|
+
questions = request.get("questions", []) if request else []
|
|
330
|
+
first_question = questions[0] if questions else {}
|
|
331
|
+
query_name = str(first_question.get("name", "unknown"))
|
|
332
|
+
query_type = first_question.get("type")
|
|
333
|
+
answers = response.get("answers", []) if response else []
|
|
334
|
+
summary: dict[str, Any] = {
|
|
335
|
+
"id": flow.id,
|
|
336
|
+
"kind": "dns",
|
|
337
|
+
"timestamp": iso_timestamp(timestamp),
|
|
338
|
+
"method": "DNS",
|
|
339
|
+
"url": f"dns://{query_name}",
|
|
340
|
+
"host": query_name,
|
|
341
|
+
"path": str(query_type or "query"),
|
|
342
|
+
"queryName": query_name,
|
|
343
|
+
"answerCount": len(answers),
|
|
344
|
+
}
|
|
345
|
+
optional(summary, "queryType", query_type)
|
|
346
|
+
if response:
|
|
347
|
+
optional(summary, "responseCode", response.get("responseCode"))
|
|
348
|
+
optional(summary, "error", flow_error(flow))
|
|
349
|
+
detail: dict[str, Any] = {
|
|
350
|
+
**summary,
|
|
351
|
+
"client": endpoint(flow.client_conn, client=True),
|
|
352
|
+
"server": endpoint(flow.server_conn, client=False),
|
|
353
|
+
"request": request or empty_dns_message(True),
|
|
354
|
+
}
|
|
355
|
+
if response:
|
|
356
|
+
detail["response"] = response
|
|
357
|
+
return summary, detail, timestamp
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def http_message(message: Any) -> dict[str, Any]:
|
|
361
|
+
result: dict[str, Any] = {
|
|
362
|
+
"headers": header_pairs(message.headers),
|
|
363
|
+
"bodyBytes": content_size(message),
|
|
364
|
+
}
|
|
365
|
+
optional(result, "httpVersion", getattr(message, "http_version", None))
|
|
366
|
+
result.update(content_preview(getattr(message, "raw_content", None), MAX_BODY_PREVIEW, "body"))
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def stream_message(message: Any, websocket: bool = False) -> dict[str, Any]:
|
|
371
|
+
content = getattr(message, "content", b"")
|
|
372
|
+
message_type = enum_name(getattr(message, "type", None)) if websocket else None
|
|
373
|
+
result: dict[str, Any] = {
|
|
374
|
+
"direction": "client" if message.from_client else "server",
|
|
375
|
+
"timestamp": iso_timestamp(float(message.timestamp)),
|
|
376
|
+
"contentBytes": byte_size(content),
|
|
377
|
+
}
|
|
378
|
+
optional(result, "messageType", message_type)
|
|
379
|
+
result.update(
|
|
380
|
+
content_preview(
|
|
381
|
+
content,
|
|
382
|
+
MAX_MESSAGE_PREVIEW,
|
|
383
|
+
"content",
|
|
384
|
+
force_binary=message_type == "binary",
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
if getattr(message, "dropped", False):
|
|
388
|
+
result["dropped"] = True
|
|
389
|
+
if getattr(message, "injected", False):
|
|
390
|
+
result["injected"] = True
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def dns_message(message: Any) -> dict[str, Any]:
|
|
395
|
+
raw = message.to_json() if callable(getattr(message, "to_json", None)) else {}
|
|
396
|
+
result = {
|
|
397
|
+
"id": int(message.id),
|
|
398
|
+
"query": bool(message.query),
|
|
399
|
+
"responseCode": int(message.response_code),
|
|
400
|
+
"questions": [dns_question(question, raw_item(raw, "questions", index)) for index, question in enumerate(message.questions)],
|
|
401
|
+
"answers": [dns_record(record, raw_item(raw, "answers", index)) for index, record in enumerate(message.answers)],
|
|
402
|
+
"authorities": [dns_record(record, raw_item(raw, "authorities", index)) for index, record in enumerate(message.authorities)],
|
|
403
|
+
"additionals": [dns_record(record, raw_item(raw, "additionals", index)) for index, record in enumerate(message.additionals)],
|
|
404
|
+
}
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def dns_question(question: Any, raw: dict[str, Any]) -> dict[str, Any]:
|
|
409
|
+
return {
|
|
410
|
+
"name": text_value(raw.get("name", question.name)),
|
|
411
|
+
"type": text_value(raw.get("type", question.type)),
|
|
412
|
+
"class": text_value(raw.get("class", raw.get("class_", question.class_))),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def dns_record(record: Any, raw: dict[str, Any]) -> dict[str, Any]:
|
|
417
|
+
result = {
|
|
418
|
+
"name": text_value(raw.get("name", record.name)),
|
|
419
|
+
"type": text_value(raw.get("type", record.type)),
|
|
420
|
+
"class": text_value(raw.get("class", raw.get("class_", record.class_))),
|
|
421
|
+
"ttl": int(record.ttl),
|
|
422
|
+
}
|
|
423
|
+
optional(result, "data", raw.get("data", text_value(record.data)))
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def content_preview(content: Any, limit: int, prefix: str, force_binary: bool = False) -> dict[str, Any]:
|
|
428
|
+
if content is None:
|
|
429
|
+
return {}
|
|
430
|
+
if isinstance(content, str):
|
|
431
|
+
encoded = content.encode("utf-8")
|
|
432
|
+
preview = encoded[:limit].decode("utf-8", errors="replace")
|
|
433
|
+
result = {f"{prefix}Text": preview}
|
|
434
|
+
if len(encoded) > limit:
|
|
435
|
+
result[f"{prefix}Truncated" if prefix == "body" else "truncated"] = True
|
|
436
|
+
return result
|
|
437
|
+
raw = bytes(content)
|
|
438
|
+
preview = raw[:limit]
|
|
439
|
+
if not force_binary and is_readable_utf8(preview):
|
|
440
|
+
result = {f"{prefix}Text": preview.decode("utf-8", errors="replace")}
|
|
441
|
+
else:
|
|
442
|
+
result = {f"{prefix}Base64": base64.b64encode(preview).decode("ascii")}
|
|
443
|
+
if len(raw) > limit:
|
|
444
|
+
result[f"{prefix}Truncated" if prefix == "body" else "truncated"] = True
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def header_pairs(headers: Any) -> list[list[str]]:
|
|
449
|
+
fields = getattr(headers, "fields", [])
|
|
450
|
+
return [[text_value(name, "latin-1"), text_value(value, "latin-1")] for name, value in fields]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def endpoint(connection: Any, client: bool) -> dict[str, Any]:
|
|
454
|
+
candidates = [
|
|
455
|
+
getattr(connection, "peername", None),
|
|
456
|
+
getattr(connection, "address", None),
|
|
457
|
+
getattr(connection, "sockname", None),
|
|
458
|
+
]
|
|
459
|
+
address = next((candidate for candidate in candidates if candidate), None)
|
|
460
|
+
if isinstance(address, (tuple, list)) and address:
|
|
461
|
+
host = text_value(address[0])
|
|
462
|
+
port = address[1] if len(address) > 1 and isinstance(address[1], int) else None
|
|
463
|
+
elif address:
|
|
464
|
+
host, port = text_value(address), None
|
|
465
|
+
else:
|
|
466
|
+
host, port = ("client" if client else "server"), None
|
|
467
|
+
return {
|
|
468
|
+
"host": host,
|
|
469
|
+
**({"port": port} if port is not None else {}),
|
|
470
|
+
"label": f"{host}:{port}" if port is not None else host,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def endpoint_hosts(flow: Any) -> list[str]:
|
|
475
|
+
return [
|
|
476
|
+
endpoint(flow.client_conn, client=True)["host"],
|
|
477
|
+
endpoint(flow.server_conn, client=False)["host"],
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def raw_item(raw: Any, field: str, index: int) -> dict[str, Any]:
|
|
482
|
+
items = raw.get(field, []) if isinstance(raw, dict) else []
|
|
483
|
+
return items[index] if index < len(items) and isinstance(items[index], dict) else {}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def empty_dns_message(query: bool) -> dict[str, Any]:
|
|
487
|
+
return {"query": query, "questions": [], "answers": [], "authorities": [], "additionals": []}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def content_size(message: Any) -> int:
|
|
491
|
+
return byte_size(getattr(message, "raw_content", None))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def byte_size(content: Any) -> int:
|
|
495
|
+
if content is None:
|
|
496
|
+
return 0
|
|
497
|
+
return len(content.encode("utf-8")) if isinstance(content, str) else len(bytes(content))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def is_readable_utf8(content: bytes) -> bool:
|
|
501
|
+
try:
|
|
502
|
+
text = content.decode("utf-8")
|
|
503
|
+
except UnicodeDecodeError:
|
|
504
|
+
return False
|
|
505
|
+
return all(character.isprintable() or character in "\r\n\t" for character in text)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def enum_name(value: Any) -> str | None:
|
|
509
|
+
if value is None:
|
|
510
|
+
return None
|
|
511
|
+
name = getattr(value, "name", None)
|
|
512
|
+
return str(name).lower() if name else str(value).lower()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def flow_error(flow: Any) -> str | None:
|
|
516
|
+
error = getattr(flow, "error", None)
|
|
517
|
+
if error is None:
|
|
518
|
+
return None
|
|
519
|
+
return text_value(getattr(error, "msg", error))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def text_value(value: Any, encoding: str = "utf-8") -> str:
|
|
523
|
+
if isinstance(value, bytes):
|
|
524
|
+
return value.decode(encoding, errors="replace")
|
|
525
|
+
if isinstance(value, (dict, list)):
|
|
526
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
527
|
+
return str(value)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def iso_timestamp(timestamp: float) -> str:
|
|
531
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def optional(target: dict[str, Any], key: str, value: Any) -> None:
|
|
535
|
+
if value is not None and value != "":
|
|
536
|
+
target[key] = value
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@server.mcp.tool()
|
|
540
|
+
async def get_flow_summary_v2(limit: int = 20, kind: str | None = None) -> str:
|
|
541
|
+
"""Return HTTP, WebSocket, TCP, UDP, and DNS flow summaries."""
|
|
542
|
+
recorder = server.controller.recorder
|
|
543
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
544
|
+
return "[]"
|
|
545
|
+
return json.dumps(recorder.get_flow_summary_v2(limit, kind), indent=2)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@server.mcp.tool()
|
|
549
|
+
async def inspect_flow_v2(flow_id: str) -> str:
|
|
550
|
+
"""Return protocol-aware detail for a captured flow."""
|
|
551
|
+
recorder = server.controller.recorder
|
|
552
|
+
if not isinstance(recorder, FaraiTrafficRecorder):
|
|
553
|
+
return "Couldn't find that flow."
|
|
554
|
+
detail = recorder.get_flow_detail_v2(flow_id)
|
|
555
|
+
return json.dumps(detail, indent=2) if detail else "Couldn't find that flow."
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
server.TrafficRecorder = FaraiTrafficRecorder
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == "__main__":
|
|
562
|
+
server.start()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
base {
|
|
2
|
+
log_debug = off;
|
|
3
|
+
log_info = off;
|
|
4
|
+
log = "stderr";
|
|
5
|
+
daemon = on;
|
|
6
|
+
redirector = iptables;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
redsocks {
|
|
10
|
+
local_ip = 127.0.0.1;
|
|
11
|
+
local_port = __LOCAL_PORT__;
|
|
12
|
+
ip = 127.0.0.1;
|
|
13
|
+
port = __PROXY_PORT__;
|
|
14
|
+
type = http-connect;
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "farai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Cyber-first freestyle local AI agent",
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"packageManager": "bun@1.3.14",
|
|
9
|
+
"homepage": "https://github.com/pajarori/farai#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/pajarori/farai.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/pajarori/farai/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"cli",
|
|
20
|
+
"terminal",
|
|
21
|
+
"cybersecurity",
|
|
22
|
+
"ctf",
|
|
23
|
+
"pentest",
|
|
24
|
+
"benchmark",
|
|
25
|
+
"mcp"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"farai": "dist/cli/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist/",
|
|
32
|
+
"docker/kali/",
|
|
33
|
+
"src/agent-skills/library/",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"README.md",
|
|
36
|
+
"package.json"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "bun run scripts/build-package.ts",
|
|
43
|
+
"dev": "bun run src/cli/index.ts",
|
|
44
|
+
"start": "bun run src/cli/index.ts",
|
|
45
|
+
"tui:test": "bun test ./test/tui-router.test.ts ./test/tui-input-architecture.test.ts ./test/tui-composer.test.ts ./test/tui-proxy-divider.test.ts ./test/tui-proxy-state.test.ts ./test/tui-footer-state.test.ts ./test/tui-renderers.test.ts ./test/tui-transcript-render.test.tsx ./test/tui-store-session-race.test.tsx ./test/tui-events.test.ts ./test/tui-runtime-port.test.ts ./test/tui-dialog-fuzzy.test.ts ./test/tui-command-registry.test.ts ./test/tui-cells.test.ts ./test/tui-clipboard.test.ts",
|
|
46
|
+
"doctor": "bun run src/cli/index.ts doctor",
|
|
47
|
+
"build:image": "bun run src/cli/index.ts build",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "bun test ./test",
|
|
50
|
+
"eval:smoke": "bun run test/evals/smoke.ts",
|
|
51
|
+
"prepare": "bun run scripts/prepare-package.ts",
|
|
52
|
+
"prepublishOnly": "bun run typecheck && bun run build",
|
|
53
|
+
"prepack": "bun run build",
|
|
54
|
+
"pack:dry": "npm pack --dry-run"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"bun": ">=1.1.0",
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@opentui/core": "0.5.8",
|
|
62
|
+
"@opentui/solid": "0.5.8",
|
|
63
|
+
"bun-pty": "0.4.10",
|
|
64
|
+
"fuzzysort": "3.1.0",
|
|
65
|
+
"solid-js": "1.9.12"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/bun": "^1.1.0",
|
|
69
|
+
"typescript": "5.9.3"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ffuf
|
|
3
|
+
description: Wordlist strategy, soft-404 filtering, and failure recovery for directory/file fuzzing via the dir_enum tool (ffuf-backed). Use this whenever the user wants to enumerate hidden directories/files/endpoints on a web target, or mentions ffuf, gobuster, or fuzzing.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Directory Enumeration Playbook (dir_enum / ffuf)
|
|
7
|
+
|
|
8
|
+
Fuzz the `FUZZ` keyword position in the URL against a wordlist.
|
|
9
|
+
|
|
10
|
+
Useful defaults:
|
|
11
|
+
- Filter noisy 200s from a catch-all page: match by size/words rather than status when the
|
|
12
|
+
target returns 200 for everything (soft-404) — compare a known-bad path's response size first.
|
|
13
|
+
- Start with a small, common wordlist (common.txt / raft-small) before escalating to a large one —
|
|
14
|
+
most useful hits show up early; a huge wordlist mostly adds noise and runtime.
|
|
15
|
+
- Add file extensions relevant to the stack once identified (`.php`, `.aspx`, `.bak`, `.json`)
|
|
16
|
+
rather than fuzzing extensions blindly from the start.
|
|
17
|
+
- Recurse only into directories that returned a real (not soft-404) response.
|
|
18
|
+
|
|
19
|
+
## Failure recovery
|
|
20
|
+
- All-200 responses with identical body size → soft-404 page; filter by size (`-fs <bytes>`) or
|
|
21
|
+
by matching a known regex instead of status code.
|
|
22
|
+
- No hits at all → verify the FUZZ position/URL is actually correct with one manual request first,
|
|
23
|
+
and confirm the target isn't returning a WAF block page for every request (check response size).
|