farai 0.1.2 → 0.1.3

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.
@@ -33,6 +33,7 @@
33
33
  "hashcat",
34
34
  "hydra",
35
35
  "impacket-scripts",
36
+ "imagemagick",
36
37
  "iproute2",
37
38
  "iptables",
38
39
  "iputils-ping",
@@ -56,6 +57,7 @@
56
57
  "nuclei",
57
58
  "openssh-client",
58
59
  "openssl",
60
+ "poppler-utils",
59
61
  "pipx",
60
62
  "procps",
61
63
  "python3",
@@ -75,6 +77,7 @@
75
77
  "steghide",
76
78
  "subfinder",
77
79
  "tcpdump",
80
+ "tesseract-ocr",
78
81
  "testssl.sh",
79
82
  "theharvester",
80
83
  "tree",
@@ -98,14 +101,17 @@
98
101
  "curl",
99
102
  "dig",
100
103
  "file",
104
+ "identify",
101
105
  "ip",
102
106
  "iptables",
103
107
  "jq",
104
108
  "nc",
105
109
  "openssl",
110
+ "pdftotext",
106
111
  "ping",
107
112
  "rg",
108
113
  "ssh",
114
+ "tesseract",
109
115
  "unzip",
110
116
  "wget",
111
117
  "whois",
@@ -5,8 +5,10 @@ from __future__ import annotations
5
5
  import base64
6
6
  import json
7
7
  import os
8
+ import re
8
9
  import sqlite3
9
10
  import sys
11
+ import uuid
10
12
  from datetime import datetime, timezone
11
13
  from typing import Any
12
14
 
@@ -28,15 +30,16 @@ def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
28
30
  MAX_BODY_PREVIEW = env_int("FARAI_PROXY_BODY_PREVIEW_BYTES", 65536, 1024, 1024 * 1024)
29
31
  MAX_MESSAGE_PREVIEW = env_int("FARAI_PROXY_MESSAGE_PREVIEW_BYTES", 16384, 1024, 256 * 1024)
30
32
  MAX_MESSAGES = env_int("FARAI_PROXY_MAX_MESSAGES", 200, 1, 1000)
33
+ REPLAY_CORRELATION_HEADER = "X-Farai-Replay-Correlation"
31
34
 
32
35
 
33
- class FlowStoreV2:
36
+ class FlowStore:
34
37
  def __init__(self, db_path: str):
35
38
  self.db_path = db_path
36
39
  with self._connect() as conn:
37
40
  conn.execute(
38
41
  """
39
- CREATE TABLE IF NOT EXISTS farai_flows_v2 (
42
+ CREATE TABLE IF NOT EXISTS farai_flows (
40
43
  id TEXT PRIMARY KEY,
41
44
  kind TEXT NOT NULL,
42
45
  timestamp REAL NOT NULL,
@@ -46,12 +49,12 @@ class FlowStoreV2:
46
49
  """
47
50
  )
48
51
  conn.execute(
49
- "CREATE INDEX IF NOT EXISTS idx_farai_flows_v2_timestamp "
50
- "ON farai_flows_v2(timestamp)"
52
+ "CREATE INDEX IF NOT EXISTS idx_farai_flows_timestamp "
53
+ "ON farai_flows(timestamp)"
51
54
  )
52
55
  conn.execute(
53
- "CREATE INDEX IF NOT EXISTS idx_farai_flows_v2_kind "
54
- "ON farai_flows_v2(kind)"
56
+ "CREATE INDEX IF NOT EXISTS idx_farai_flows_kind "
57
+ "ON farai_flows(kind)"
55
58
  )
56
59
 
57
60
  def _connect(self) -> sqlite3.Connection:
@@ -65,7 +68,7 @@ class FlowStoreV2:
65
68
  with self._connect() as conn:
66
69
  conn.execute(
67
70
  """
68
- INSERT INTO farai_flows_v2(id, kind, timestamp, summary_json, detail_json)
71
+ INSERT INTO farai_flows(id, kind, timestamp, summary_json, detail_json)
69
72
  VALUES (?, ?, ?, ?, ?)
70
73
  ON CONFLICT(id) DO UPDATE SET
71
74
  kind=excluded.kind,
@@ -78,7 +81,7 @@ class FlowStoreV2:
78
81
 
79
82
  def summaries(self, limit: int, kind: str | None = None) -> list[dict[str, Any]]:
80
83
  bounded_limit = max(1, min(int(limit), 1000))
81
- sql = "SELECT summary_json FROM farai_flows_v2"
84
+ sql = "SELECT summary_json FROM farai_flows"
82
85
  params: list[Any] = []
83
86
  if kind:
84
87
  sql += " WHERE kind = ?"
@@ -92,22 +95,42 @@ class FlowStoreV2:
92
95
  def detail(self, flow_id: str) -> dict[str, Any] | None:
93
96
  with self._connect() as conn:
94
97
  row = conn.execute(
95
- "SELECT detail_json FROM farai_flows_v2 WHERE id = ?", (flow_id,)
98
+ "SELECT detail_json FROM farai_flows WHERE id = ?", (flow_id,)
96
99
  ).fetchone()
97
100
  return json.loads(row[0]) if row else None
98
101
 
99
102
  def clear(self) -> None:
100
103
  with self._connect() as conn:
101
- conn.execute("DELETE FROM farai_flows_v2")
104
+ conn.execute("DELETE FROM farai_flows")
102
105
 
103
106
 
104
107
  class FaraiTrafficRecorder(UPSTREAM_TRAFFIC_RECORDER):
105
108
  def __init__(self, scope: Any):
106
109
  super().__init__(scope)
107
- self.v2 = FlowStoreV2(self.db.db_path)
110
+ self.flows = FlowStore(self.db.db_path)
111
+ self.intercept_config: dict[str, Any] = {
112
+ "enabled": False,
113
+ "host_pattern": ".*",
114
+ "path_pattern": ".*",
115
+ "methods": [],
116
+ }
117
+ self.pending_intercepts: dict[str, http.HTTPFlow] = {}
118
+ self.replay_correlations: dict[str, dict[str, str]] = {}
108
119
 
109
120
  def request(self, flow: http.HTTPFlow) -> None:
121
+ correlation = flow.request.headers.get(REPLAY_CORRELATION_HEADER)
122
+ if correlation:
123
+ flow.request.headers.pop(REPLAY_CORRELATION_HEADER, None)
124
+ replay = self.replay_correlations.get(str(correlation))
125
+ if replay is not None:
126
+ replay["descendantFlowId"] = flow.id
127
+ metadata = getattr(flow, "metadata", None)
128
+ if isinstance(metadata, dict):
129
+ metadata["faraiReplayParentId"] = replay["parentFlowId"]
110
130
  super().request(flow)
131
+ if self._should_intercept(flow):
132
+ flow.intercept()
133
+ self.pending_intercepts[flow.id] = flow
111
134
  self._capture(flow)
112
135
 
113
136
  def response(self, flow: http.HTTPFlow) -> None:
@@ -160,24 +183,106 @@ class FaraiTrafficRecorder(UPSTREAM_TRAFFIC_RECORDER):
160
183
  def dns_error(self, flow: dns.DNSFlow) -> None:
161
184
  self._capture(flow)
162
185
 
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)
186
+ def farai_flow_summaries(self, limit: int = 20, kind: str | None = None) -> list[dict[str, Any]]:
187
+ return self.flows.summaries(limit, kind)
165
188
 
166
- def get_flow_detail_v2(self, flow_id: str) -> dict[str, Any] | None:
167
- return self.v2.detail(flow_id)
189
+ def farai_flow_detail(self, flow_id: str) -> dict[str, Any] | None:
190
+ return self.flows.detail(flow_id)
168
191
 
169
192
  def clear(self) -> None:
170
193
  super().clear()
171
- self.v2.clear()
194
+ self.flows.clear()
195
+
196
+ def scope_state(self) -> dict[str, Any]:
197
+ return {"allowedDomains": list(self.scope.config.allowed_domains)}
198
+
199
+ def configure_intercept(
200
+ self,
201
+ enabled: bool,
202
+ host_pattern: str = ".*",
203
+ path_pattern: str = ".*",
204
+ methods: list[str] | None = None,
205
+ ) -> dict[str, Any]:
206
+ re.compile(host_pattern)
207
+ re.compile(path_pattern)
208
+ self.intercept_config = {
209
+ "enabled": bool(enabled),
210
+ "host_pattern": host_pattern,
211
+ "path_pattern": path_pattern,
212
+ "methods": sorted({method.upper() for method in (methods or [])}),
213
+ }
214
+ return {**self.intercept_config, "pending": len(self.pending_intercepts)}
215
+
216
+ def list_intercepts(self) -> list[dict[str, Any]]:
217
+ result: list[dict[str, Any]] = []
218
+ for flow in self.pending_intercepts.values():
219
+ summary, _, _ = serialize_http(flow)
220
+ result.append(summary)
221
+ return sorted(result, key=lambda item: str(item.get("timestamp", "")))
222
+
223
+ def begin_replay(self, parent_flow_id: str) -> tuple[str, dict[str, str]]:
224
+ token = uuid.uuid4().hex
225
+ state = {"parentFlowId": parent_flow_id}
226
+ self.replay_correlations[token] = state
227
+ return token, state
228
+
229
+ def finish_replay(self, token: str) -> dict[str, str]:
230
+ return self.replay_correlations.pop(token, {})
231
+
232
+ def resolve_intercept(
233
+ self,
234
+ flow_id: str,
235
+ action: str,
236
+ method: str | None = None,
237
+ url: str | None = None,
238
+ headers: dict[str, str] | None = None,
239
+ body: str | None = None,
240
+ ) -> dict[str, Any]:
241
+ flow = self.pending_intercepts.pop(flow_id, None)
242
+ if flow is None:
243
+ raise ValueError(f"Unknown pending intercepted flow: {flow_id}")
244
+ if action == "drop":
245
+ flow.kill()
246
+ return {"flowId": flow_id, "action": "drop"}
247
+ if action not in {"forward", "edit"}:
248
+ self.pending_intercepts[flow_id] = flow
249
+ raise ValueError("action must be forward, edit, or drop")
250
+ if action == "edit":
251
+ if method:
252
+ flow.request.method = method.upper()
253
+ if url:
254
+ flow.request.url = url
255
+ for key, value in (headers or {}).items():
256
+ flow.request.headers[key] = value
257
+ if body is not None:
258
+ flow.request.text = body
259
+ flow.resume()
260
+ return {"flowId": flow_id, "action": action}
261
+
262
+ def _should_intercept(self, flow: http.HTTPFlow) -> bool:
263
+ config = self.intercept_config
264
+ if not config["enabled"] or flow.id in self.pending_intercepts:
265
+ return False
266
+ methods = config["methods"]
267
+ return (
268
+ (not methods or flow.request.method.upper() in methods)
269
+ and re.search(config["host_pattern"], http_display_host(flow.request)) is not None
270
+ and re.search(config["path_pattern"], flow.request.path) is not None
271
+ )
172
272
 
173
273
  def _capture(self, flow: Any) -> None:
174
274
  try:
175
275
  if not self._is_allowed(flow):
176
276
  return
177
277
  summary, detail, timestamp = serialize_flow(flow)
178
- self.v2.save(summary, detail, timestamp)
278
+ metadata = getattr(flow, "metadata", None)
279
+ parent_flow_id = metadata.get("faraiReplayParentId") if isinstance(metadata, dict) else None
280
+ if parent_flow_id:
281
+ summary["parentFlowId"] = parent_flow_id
282
+ detail["parentFlowId"] = parent_flow_id
283
+ self.flows.save(summary, detail, timestamp)
179
284
  except Exception as exc:
180
- print(f"Failed to save Farai v2 flow: {exc}", file=sys.stderr)
285
+ print(f"Failed to save Farai flow: {exc}", file=sys.stderr)
181
286
 
182
287
  def _is_allowed(self, flow: Any) -> bool:
183
288
  if isinstance(flow, http.HTTPFlow):
@@ -537,24 +642,150 @@ def optional(target: dict[str, Any], key: str, value: Any) -> None:
537
642
 
538
643
 
539
644
  @server.mcp.tool()
540
- async def get_flow_summary_v2(limit: int = 20, kind: str | None = None) -> str:
645
+ async def proxy_flow_summaries(limit: int = 20, kind: str | None = None) -> str:
541
646
  """Return HTTP, WebSocket, TCP, UDP, and DNS flow summaries."""
542
647
  recorder = server.controller.recorder
543
648
  if not isinstance(recorder, FaraiTrafficRecorder):
544
649
  return "[]"
545
- return json.dumps(recorder.get_flow_summary_v2(limit, kind), indent=2)
650
+ return json.dumps(recorder.farai_flow_summaries(limit, kind), indent=2)
546
651
 
547
652
 
548
653
  @server.mcp.tool()
549
- async def inspect_flow_v2(flow_id: str) -> str:
654
+ async def proxy_flow_inspect(flow_id: str) -> str:
550
655
  """Return protocol-aware detail for a captured flow."""
551
656
  recorder = server.controller.recorder
552
657
  if not isinstance(recorder, FaraiTrafficRecorder):
553
658
  return "Couldn't find that flow."
554
- detail = recorder.get_flow_detail_v2(flow_id)
659
+ detail = recorder.farai_flow_detail(flow_id)
555
660
  return json.dumps(detail, indent=2) if detail else "Couldn't find that flow."
556
661
 
557
662
 
663
+ @server.mcp.tool()
664
+ async def proxy_replay_correlated(
665
+ flow_id: str,
666
+ method: str | None = None,
667
+ headers_json: str | None = None,
668
+ body: str | None = None,
669
+ timeout: float = 30.0,
670
+ ) -> str:
671
+ """Replay an HTTP flow and return its exact captured descendant flow id."""
672
+ recorder = server.controller.recorder
673
+ if not isinstance(recorder, FaraiTrafficRecorder):
674
+ return json.dumps({"ok": False, "error": "Farai recorder is unavailable."})
675
+ if recorder.farai_flow_detail(flow_id) is None:
676
+ return json.dumps({"ok": False, "error": f"Couldn't find flow {flow_id}."})
677
+ try:
678
+ headers = json.loads(headers_json) if headers_json else {}
679
+ except json.JSONDecodeError as exc:
680
+ return json.dumps({"ok": False, "error": f"Invalid headers_json: {exc}"})
681
+ if not isinstance(headers, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in headers.items()):
682
+ return json.dumps({"ok": False, "error": "headers_json must encode a string-to-string object."})
683
+
684
+ # Upstream treats None as "reuse the captured body"; an empty string omits it.
685
+ resolved_body = "" if body == "__omit__" else body
686
+ variables = getattr(server.controller, "session_variables", {})
687
+ resolver = getattr(server, "_resolve_template", None)
688
+ if variables and callable(resolver):
689
+ headers = json.loads(resolver(json.dumps(headers), variables))
690
+ if resolved_body:
691
+ resolved_body = resolver(resolved_body, variables)
692
+
693
+ token, correlation = recorder.begin_replay(flow_id)
694
+ headers[REPLAY_CORRELATION_HEADER] = token
695
+ try:
696
+ message = await server.controller.replay_request(flow_id, method, headers, resolved_body, timeout)
697
+ result = recorder.finish_replay(token)
698
+ except Exception as exc:
699
+ recorder.finish_replay(token)
700
+ return json.dumps({"ok": False, "parentFlowId": flow_id, "error": str(exc)})
701
+ ok = not any(marker in message.lower() for marker in ("couldn't find", "didn't work", "invalid"))
702
+ return json.dumps(
703
+ {
704
+ "ok": ok,
705
+ **correlation,
706
+ **result,
707
+ "message": message,
708
+ **({} if ok else {"error": message}),
709
+ },
710
+ indent=2,
711
+ )
712
+
713
+
714
+ @server.mcp.tool()
715
+ async def proxy_scope_get() -> str:
716
+ """Return Farai's active mitmproxy capture scope."""
717
+ recorder = server.controller.recorder
718
+ if not isinstance(recorder, FaraiTrafficRecorder):
719
+ return json.dumps({"allowedDomains": []})
720
+ return json.dumps(recorder.scope_state(), indent=2)
721
+
722
+
723
+ @server.mcp.tool()
724
+ async def proxy_intercept_configure(
725
+ enabled: bool,
726
+ host_pattern: str = ".*",
727
+ path_pattern: str = ".*",
728
+ methods: list[str] | None = None,
729
+ ) -> str:
730
+ """Enable or disable Farai's manual request interception queue."""
731
+ recorder = server.controller.recorder
732
+ if not isinstance(recorder, FaraiTrafficRecorder):
733
+ raise RuntimeError("Farai recorder is unavailable.")
734
+ try:
735
+ return json.dumps(
736
+ recorder.configure_intercept(enabled, host_pattern, path_pattern, methods),
737
+ indent=2,
738
+ )
739
+ except re.error as exc:
740
+ raise ValueError(f"Invalid interception pattern: {exc}") from exc
741
+
742
+
743
+ @server.mcp.tool()
744
+ async def proxy_intercept_get() -> str:
745
+ """Return Farai's manual interception configuration and queue size."""
746
+ recorder = server.controller.recorder
747
+ if not isinstance(recorder, FaraiTrafficRecorder):
748
+ return json.dumps({"enabled": False, "pending": 0})
749
+ return json.dumps(
750
+ {**recorder.intercept_config, "pending": len(recorder.pending_intercepts)},
751
+ indent=2,
752
+ )
753
+
754
+
755
+ @server.mcp.tool()
756
+ async def proxy_intercept_list() -> str:
757
+ """List requests currently paused in Farai's interception queue."""
758
+ recorder = server.controller.recorder
759
+ if not isinstance(recorder, FaraiTrafficRecorder):
760
+ return "[]"
761
+ return json.dumps(recorder.list_intercepts(), indent=2)
762
+
763
+
764
+ @server.mcp.tool()
765
+ async def proxy_intercept_resolve(
766
+ flow_id: str,
767
+ action: str,
768
+ method: str | None = None,
769
+ url: str | None = None,
770
+ headers_json: str | None = None,
771
+ body: str | None = None,
772
+ ) -> str:
773
+ """Forward, edit, or drop one request from Farai's interception queue."""
774
+ recorder = server.controller.recorder
775
+ if not isinstance(recorder, FaraiTrafficRecorder):
776
+ raise RuntimeError("Farai recorder is unavailable.")
777
+ headers = json.loads(headers_json) if headers_json else None
778
+ if headers is not None and (
779
+ not isinstance(headers, dict)
780
+ or not all(isinstance(key, str) and isinstance(value, str) for key, value in headers.items())
781
+ ):
782
+ raise ValueError("headers_json must encode a string-to-string object.")
783
+ return json.dumps(
784
+ recorder.resolve_intercept(flow_id, action, method, url, headers, body),
785
+ indent=2,
786
+ )
787
+
788
+
558
789
  server.TrafficRecorder = FaraiTrafficRecorder
559
790
 
560
791
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "farai",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Cyber-first freestyle local AI agent",
@@ -60,6 +60,7 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@opentui/core": "0.5.8",
63
+ "ajv": "8.20.0",
63
64
  "bun-pty": "0.4.10",
64
65
  "entities": "7.0.1",
65
66
  "fuzzysort": "3.1.0",