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.
@@ -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).