strix-agent 0.1.18__py3-none-any.whl → 0.3.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.
Potentially problematic release.
This version of strix-agent might be problematic. Click here for more details.
- strix/agents/StrixAgent/strix_agent.py +49 -39
- strix/agents/StrixAgent/system_prompt.jinja +23 -10
- strix/agents/base_agent.py +90 -10
- strix/agents/state.py +23 -2
- strix/interface/cli.py +171 -0
- strix/interface/main.py +482 -0
- strix/{cli → interface}/tool_components/base_renderer.py +2 -2
- strix/{cli → interface}/tool_components/reporting_renderer.py +2 -1
- strix/{cli → interface}/tool_components/scan_info_renderer.py +17 -12
- strix/{cli/app.py → interface/tui.py} +107 -31
- strix/interface/utils.py +435 -0
- strix/prompts/README.md +64 -0
- strix/prompts/__init__.py +1 -1
- strix/prompts/cloud/.gitkeep +0 -0
- strix/prompts/custom/.gitkeep +0 -0
- strix/prompts/frameworks/fastapi.jinja +142 -0
- strix/prompts/frameworks/nextjs.jinja +126 -0
- strix/prompts/protocols/graphql.jinja +215 -0
- strix/prompts/reconnaissance/.gitkeep +0 -0
- strix/prompts/technologies/firebase_firestore.jinja +177 -0
- strix/prompts/technologies/supabase.jinja +189 -0
- strix/prompts/vulnerabilities/authentication_jwt.jinja +133 -115
- strix/prompts/vulnerabilities/broken_function_level_authorization.jinja +146 -0
- strix/prompts/vulnerabilities/business_logic.jinja +146 -118
- strix/prompts/vulnerabilities/csrf.jinja +137 -131
- strix/prompts/vulnerabilities/idor.jinja +149 -118
- strix/prompts/vulnerabilities/insecure_file_uploads.jinja +188 -0
- strix/prompts/vulnerabilities/mass_assignment.jinja +141 -0
- strix/prompts/vulnerabilities/path_traversal_lfi_rfi.jinja +142 -0
- strix/prompts/vulnerabilities/race_conditions.jinja +135 -165
- strix/prompts/vulnerabilities/rce.jinja +128 -180
- strix/prompts/vulnerabilities/sql_injection.jinja +128 -192
- strix/prompts/vulnerabilities/ssrf.jinja +118 -151
- strix/prompts/vulnerabilities/xss.jinja +144 -196
- strix/prompts/vulnerabilities/xxe.jinja +151 -243
- strix/runtime/docker_runtime.py +28 -7
- strix/runtime/runtime.py +4 -1
- strix/telemetry/__init__.py +4 -0
- strix/{cli → telemetry}/tracer.py +21 -9
- strix/tools/agents_graph/agents_graph_actions.py +17 -12
- strix/tools/agents_graph/agents_graph_actions_schema.xml +10 -14
- strix/tools/executor.py +1 -1
- strix/tools/finish/finish_actions.py +1 -1
- strix/tools/registry.py +1 -1
- strix/tools/reporting/reporting_actions.py +1 -1
- {strix_agent-0.1.18.dist-info → strix_agent-0.3.1.dist-info}/METADATA +95 -15
- strix_agent-0.3.1.dist-info/RECORD +115 -0
- strix_agent-0.3.1.dist-info/entry_points.txt +3 -0
- strix/cli/main.py +0 -702
- strix_agent-0.1.18.dist-info/RECORD +0 -99
- strix_agent-0.1.18.dist-info/entry_points.txt +0 -3
- /strix/{cli → interface}/__init__.py +0 -0
- /strix/{cli/assets/cli.tcss → interface/assets/tui_styles.tcss} +0 -0
- /strix/{cli → interface}/tool_components/__init__.py +0 -0
- /strix/{cli → interface}/tool_components/agents_graph_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/browser_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/file_edit_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/finish_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/notes_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/proxy_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/python_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/registry.py +0 -0
- /strix/{cli → interface}/tool_components/terminal_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/thinking_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/user_message_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/web_search_renderer.py +0 -0
- {strix_agent-0.1.18.dist-info → strix_agent-0.3.1.dist-info}/LICENSE +0 -0
- {strix_agent-0.1.18.dist-info → strix_agent-0.3.1.dist-info}/WHEEL +0 -0
|
@@ -7,9 +7,19 @@ import signal
|
|
|
7
7
|
import sys
|
|
8
8
|
import threading
|
|
9
9
|
from collections.abc import Callable
|
|
10
|
-
from
|
|
10
|
+
from importlib.metadata import PackageNotFoundError
|
|
11
|
+
from importlib.metadata import version as pkg_version
|
|
12
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
11
13
|
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from textual.timer import Timer
|
|
17
|
+
|
|
18
|
+
from rich.align import Align
|
|
19
|
+
from rich.console import Group
|
|
12
20
|
from rich.markup import escape as rich_escape
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.style import Style
|
|
13
23
|
from rich.text import Text
|
|
14
24
|
from textual import events, on
|
|
15
25
|
from textual.app import App, ComposeResult
|
|
@@ -21,20 +31,27 @@ from textual.widgets import Button, Label, Static, TextArea, Tree
|
|
|
21
31
|
from textual.widgets.tree import TreeNode
|
|
22
32
|
|
|
23
33
|
from strix.agents.StrixAgent import StrixAgent
|
|
24
|
-
from strix.cli.tracer import Tracer, set_global_tracer
|
|
25
34
|
from strix.llm.config import LLMConfig
|
|
35
|
+
from strix.telemetry.tracer import Tracer, set_global_tracer
|
|
26
36
|
|
|
27
37
|
|
|
28
38
|
def escape_markup(text: str) -> str:
|
|
29
|
-
return rich_escape(text)
|
|
39
|
+
return cast("str", rich_escape(text))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_package_version() -> str:
|
|
43
|
+
try:
|
|
44
|
+
return pkg_version("strix-agent")
|
|
45
|
+
except PackageNotFoundError:
|
|
46
|
+
return "dev"
|
|
30
47
|
|
|
31
48
|
|
|
32
49
|
class ChatTextArea(TextArea): # type: ignore[misc]
|
|
33
50
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
34
51
|
super().__init__(*args, **kwargs)
|
|
35
|
-
self._app_reference:
|
|
52
|
+
self._app_reference: StrixTUIApp | None = None
|
|
36
53
|
|
|
37
|
-
def set_app_reference(self, app: "
|
|
54
|
+
def set_app_reference(self, app: "StrixTUIApp") -> None:
|
|
38
55
|
self._app_reference = app
|
|
39
56
|
|
|
40
57
|
def _on_key(self, event: events.Key) -> None:
|
|
@@ -53,24 +70,85 @@ class ChatTextArea(TextArea): # type: ignore[misc]
|
|
|
53
70
|
|
|
54
71
|
|
|
55
72
|
class SplashScreen(Static): # type: ignore[misc]
|
|
73
|
+
PRIMARY_GREEN = "#22c55e"
|
|
74
|
+
BANNER = (
|
|
75
|
+
" ███████╗████████╗██████╗ ██╗██╗ ██╗\n"
|
|
76
|
+
" ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝\n"
|
|
77
|
+
" ███████╗ ██║ ██████╔╝██║ ╚███╔╝\n"
|
|
78
|
+
" ╚════██║ ██║ ██╔══██╗██║ ██╔██╗\n"
|
|
79
|
+
" ███████║ ██║ ██║ ██║██║██╔╝ ██╗\n"
|
|
80
|
+
" ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
84
|
+
super().__init__(*args, **kwargs)
|
|
85
|
+
self._animation_step = 0
|
|
86
|
+
self._animation_timer: Timer | None = None
|
|
87
|
+
self._panel_static: Static | None = None
|
|
88
|
+
self._version = "dev"
|
|
89
|
+
|
|
56
90
|
def compose(self) -> ComposeResult:
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
self._version = get_package_version()
|
|
92
|
+
self._animation_step = 0
|
|
93
|
+
start_line = self._build_start_line_text(self._animation_step)
|
|
94
|
+
panel = self._build_panel(start_line)
|
|
95
|
+
|
|
96
|
+
panel_static = Static(panel, id="splash_content")
|
|
97
|
+
self._panel_static = panel_static
|
|
98
|
+
yield panel_static
|
|
99
|
+
|
|
100
|
+
def on_mount(self) -> None:
|
|
101
|
+
self._animation_timer = self.set_interval(0.45, self._animate_start_line)
|
|
102
|
+
|
|
103
|
+
def on_unmount(self) -> None:
|
|
104
|
+
if self._animation_timer is not None:
|
|
105
|
+
self._animation_timer.stop()
|
|
106
|
+
self._animation_timer = None
|
|
107
|
+
|
|
108
|
+
def _animate_start_line(self) -> None:
|
|
109
|
+
if not self._panel_static:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self._animation_step += 1
|
|
113
|
+
start_line = self._build_start_line_text(self._animation_step)
|
|
114
|
+
panel = self._build_panel(start_line)
|
|
115
|
+
self._panel_static.update(panel)
|
|
116
|
+
|
|
117
|
+
def _build_panel(self, start_line: Text) -> Panel:
|
|
118
|
+
content = Group(
|
|
119
|
+
Align.center(Text(self.BANNER.strip("\n"), style=self.PRIMARY_GREEN, justify="center")),
|
|
120
|
+
Align.center(Text(" ")),
|
|
121
|
+
Align.center(self._build_welcome_text()),
|
|
122
|
+
Align.center(self._build_version_text()),
|
|
123
|
+
Align.center(self._build_tagline_text()),
|
|
124
|
+
Align.center(Text(" ")),
|
|
125
|
+
Align.center(start_line.copy()),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
|
|
129
|
+
|
|
130
|
+
def _build_welcome_text(self) -> Text:
|
|
131
|
+
text = Text("Welcome to ", style=Style(color="white", bold=True))
|
|
132
|
+
text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True))
|
|
133
|
+
text.append("!", style=Style(color="white", bold=True))
|
|
134
|
+
return text
|
|
59
135
|
|
|
136
|
+
def _build_version_text(self) -> Text:
|
|
137
|
+
return Text(f"v{self._version}", style=Style(color="white", dim=True))
|
|
60
138
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
███████╗ ██║ ██████╔╝██║ ╚███╔╝
|
|
64
|
-
╚════██║ ██║ ██╔══██╗██║ ██╔██╗
|
|
65
|
-
███████║ ██║ ██║ ██║██║██╔╝ ██╗
|
|
66
|
-
╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝
|
|
139
|
+
def _build_tagline_text(self) -> Text:
|
|
140
|
+
return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True))
|
|
67
141
|
|
|
142
|
+
def _build_start_line_text(self, phase: int) -> Text:
|
|
143
|
+
emphasize = phase % 2 == 1
|
|
144
|
+
base_style = Style(color="white", dim=not emphasize, bold=emphasize)
|
|
145
|
+
strix_style = Style(color=self.PRIMARY_GREEN, bold=bool(emphasize))
|
|
68
146
|
|
|
69
|
-
|
|
147
|
+
text = Text("Starting ", style=base_style)
|
|
148
|
+
text.append("Strix", style=strix_style)
|
|
149
|
+
text.append(" Cybersecurity Agent", style=base_style)
|
|
70
150
|
|
|
71
|
-
|
|
72
|
-
"""
|
|
73
|
-
yield Static(ascii_art, id="splash_content")
|
|
151
|
+
return text
|
|
74
152
|
|
|
75
153
|
|
|
76
154
|
class HelpScreen(ModalScreen): # type: ignore[misc]
|
|
@@ -182,8 +260,8 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
|
|
|
182
260
|
self.app.pop_screen()
|
|
183
261
|
|
|
184
262
|
|
|
185
|
-
class
|
|
186
|
-
CSS_PATH = "assets/
|
|
263
|
+
class StrixTUIApp(App): # type: ignore[misc]
|
|
264
|
+
CSS_PATH = "assets/tui_styles.tcss"
|
|
187
265
|
|
|
188
266
|
selected_agent_id: reactive[str | None] = reactive(default=None)
|
|
189
267
|
show_splash: reactive[bool] = reactive(default=True)
|
|
@@ -234,8 +312,7 @@ class StrixCLIApp(App): # type: ignore[misc]
|
|
|
234
312
|
def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
|
|
235
313
|
return {
|
|
236
314
|
"scan_id": args.run_name,
|
|
237
|
-
"
|
|
238
|
-
"target": args.target_dict,
|
|
315
|
+
"targets": args.targets_info,
|
|
239
316
|
"user_instructions": args.instruction or "",
|
|
240
317
|
"run_name": args.run_name,
|
|
241
318
|
}
|
|
@@ -245,13 +322,11 @@ class StrixCLIApp(App): # type: ignore[misc]
|
|
|
245
322
|
|
|
246
323
|
config = {
|
|
247
324
|
"llm_config": llm_config,
|
|
248
|
-
"max_iterations":
|
|
325
|
+
"max_iterations": 300,
|
|
249
326
|
}
|
|
250
327
|
|
|
251
|
-
if args
|
|
252
|
-
config["
|
|
253
|
-
elif args.target_type == "repository" and "cloned_repo_path" in args.target_dict:
|
|
254
|
-
config["local_source_path"] = args.target_dict["cloned_repo_path"]
|
|
328
|
+
if getattr(args, "local_sources", None):
|
|
329
|
+
config["local_sources"] = args.local_sources
|
|
255
330
|
|
|
256
331
|
return config
|
|
257
332
|
|
|
@@ -362,7 +437,7 @@ class StrixCLIApp(App): # type: ignore[misc]
|
|
|
362
437
|
def on_mount(self) -> None:
|
|
363
438
|
self.title = "strix"
|
|
364
439
|
|
|
365
|
-
self.set_timer(
|
|
440
|
+
self.set_timer(4.5, self._hide_splash_screen)
|
|
366
441
|
|
|
367
442
|
def _hide_splash_screen(self) -> None:
|
|
368
443
|
self.show_splash = False
|
|
@@ -884,7 +959,7 @@ class StrixCLIApp(App): # type: ignore[misc]
|
|
|
884
959
|
return ""
|
|
885
960
|
|
|
886
961
|
if role == "user":
|
|
887
|
-
from strix.
|
|
962
|
+
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
|
|
888
963
|
|
|
889
964
|
return UserMessageRenderer.render_simple(content)
|
|
890
965
|
return content
|
|
@@ -914,7 +989,7 @@ class StrixCLIApp(App): # type: ignore[misc]
|
|
|
914
989
|
|
|
915
990
|
color = tool_colors.get(tool_name, "#737373")
|
|
916
991
|
|
|
917
|
-
from strix.
|
|
992
|
+
from strix.interface.tool_components.registry import get_tool_renderer
|
|
918
993
|
|
|
919
994
|
renderer = get_tool_renderer(tool_name)
|
|
920
995
|
|
|
@@ -1159,6 +1234,7 @@ class StrixCLIApp(App): # type: ignore[misc]
|
|
|
1159
1234
|
widget.update(plain_text)
|
|
1160
1235
|
|
|
1161
1236
|
|
|
1162
|
-
async def
|
|
1163
|
-
|
|
1237
|
+
async def run_tui(args: argparse.Namespace) -> None:
|
|
1238
|
+
"""Run strix in interactive TUI mode with textual."""
|
|
1239
|
+
app = StrixTUIApp(args)
|
|
1164
1240
|
await app.run_async()
|
strix/interface/utils.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import secrets
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import docker
|
|
12
|
+
from docker.errors import DockerException, ImageNotFound
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Token formatting utilities
|
|
19
|
+
def format_token_count(count: float) -> str:
|
|
20
|
+
count = int(count)
|
|
21
|
+
if count >= 1_000_000:
|
|
22
|
+
return f"{count / 1_000_000:.1f}M"
|
|
23
|
+
if count >= 1_000:
|
|
24
|
+
return f"{count / 1_000:.1f}K"
|
|
25
|
+
return str(count)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Display utilities
|
|
29
|
+
def get_severity_color(severity: str) -> str:
|
|
30
|
+
severity_colors = {
|
|
31
|
+
"critical": "#dc2626",
|
|
32
|
+
"high": "#ea580c",
|
|
33
|
+
"medium": "#d97706",
|
|
34
|
+
"low": "#65a30d",
|
|
35
|
+
"info": "#0284c7",
|
|
36
|
+
}
|
|
37
|
+
return severity_colors.get(severity, "#6b7280")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_stats_text(tracer: Any) -> Text:
|
|
41
|
+
stats_text = Text()
|
|
42
|
+
if not tracer:
|
|
43
|
+
return stats_text
|
|
44
|
+
|
|
45
|
+
vuln_count = len(tracer.vulnerability_reports)
|
|
46
|
+
tool_count = tracer.get_real_tool_count()
|
|
47
|
+
agent_count = len(tracer.agents)
|
|
48
|
+
|
|
49
|
+
if vuln_count > 0:
|
|
50
|
+
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
51
|
+
for report in tracer.vulnerability_reports:
|
|
52
|
+
severity = report.get("severity", "").lower()
|
|
53
|
+
if severity in severity_counts:
|
|
54
|
+
severity_counts[severity] += 1
|
|
55
|
+
|
|
56
|
+
stats_text.append("🔍 Vulnerabilities Found: ", style="bold red")
|
|
57
|
+
|
|
58
|
+
severity_parts = []
|
|
59
|
+
for severity in ["critical", "high", "medium", "low", "info"]:
|
|
60
|
+
count = severity_counts[severity]
|
|
61
|
+
if count > 0:
|
|
62
|
+
severity_color = get_severity_color(severity)
|
|
63
|
+
severity_text = Text()
|
|
64
|
+
severity_text.append(f"{severity.upper()}: ", style=severity_color)
|
|
65
|
+
severity_text.append(str(count), style=f"bold {severity_color}")
|
|
66
|
+
severity_parts.append(severity_text)
|
|
67
|
+
|
|
68
|
+
for i, part in enumerate(severity_parts):
|
|
69
|
+
stats_text.append(part)
|
|
70
|
+
if i < len(severity_parts) - 1:
|
|
71
|
+
stats_text.append(" | ", style="dim white")
|
|
72
|
+
|
|
73
|
+
stats_text.append(" (Total: ", style="dim white")
|
|
74
|
+
stats_text.append(str(vuln_count), style="bold yellow")
|
|
75
|
+
stats_text.append(")", style="dim white")
|
|
76
|
+
stats_text.append("\n")
|
|
77
|
+
else:
|
|
78
|
+
stats_text.append("🔍 Vulnerabilities Found: ", style="bold green")
|
|
79
|
+
stats_text.append("0", style="bold white")
|
|
80
|
+
stats_text.append(" (No exploitable vulnerabilities detected)", style="dim green")
|
|
81
|
+
stats_text.append("\n")
|
|
82
|
+
|
|
83
|
+
stats_text.append("🤖 Agents Used: ", style="bold cyan")
|
|
84
|
+
stats_text.append(str(agent_count), style="bold white")
|
|
85
|
+
stats_text.append(" • ", style="dim white")
|
|
86
|
+
stats_text.append("🛠️ Tools Called: ", style="bold cyan")
|
|
87
|
+
stats_text.append(str(tool_count), style="bold white")
|
|
88
|
+
|
|
89
|
+
return stats_text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_llm_stats_text(tracer: Any) -> Text:
|
|
93
|
+
llm_stats_text = Text()
|
|
94
|
+
if not tracer:
|
|
95
|
+
return llm_stats_text
|
|
96
|
+
|
|
97
|
+
llm_stats = tracer.get_total_llm_stats()
|
|
98
|
+
total_stats = llm_stats["total"]
|
|
99
|
+
|
|
100
|
+
if total_stats["requests"] > 0:
|
|
101
|
+
llm_stats_text.append("📥 Input Tokens: ", style="bold cyan")
|
|
102
|
+
llm_stats_text.append(format_token_count(total_stats["input_tokens"]), style="bold white")
|
|
103
|
+
|
|
104
|
+
if total_stats["cached_tokens"] > 0:
|
|
105
|
+
llm_stats_text.append(" • ", style="dim white")
|
|
106
|
+
llm_stats_text.append("⚡ Cached: ", style="bold green")
|
|
107
|
+
llm_stats_text.append(
|
|
108
|
+
format_token_count(total_stats["cached_tokens"]), style="bold green"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
llm_stats_text.append(" • ", style="dim white")
|
|
112
|
+
llm_stats_text.append("📤 Output Tokens: ", style="bold cyan")
|
|
113
|
+
llm_stats_text.append(format_token_count(total_stats["output_tokens"]), style="bold white")
|
|
114
|
+
|
|
115
|
+
if total_stats["cost"] > 0:
|
|
116
|
+
llm_stats_text.append(" • ", style="dim white")
|
|
117
|
+
llm_stats_text.append("💰 Total Cost: $", style="bold cyan")
|
|
118
|
+
llm_stats_text.append(f"{total_stats['cost']:.4f}", style="bold yellow")
|
|
119
|
+
|
|
120
|
+
return llm_stats_text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Name generation utilities
|
|
124
|
+
def generate_run_name() -> str:
|
|
125
|
+
# fmt: off
|
|
126
|
+
adjectives = [
|
|
127
|
+
"stealthy", "sneaky", "crafty", "elite", "phantom", "shadow", "silent",
|
|
128
|
+
"rogue", "covert", "ninja", "ghost", "cyber", "digital", "binary",
|
|
129
|
+
"encrypted", "obfuscated", "masked", "cloaked", "invisible", "anonymous"
|
|
130
|
+
]
|
|
131
|
+
nouns = [
|
|
132
|
+
"exploit", "payload", "backdoor", "rootkit", "keylogger", "botnet", "trojan",
|
|
133
|
+
"worm", "virus", "packet", "buffer", "shell", "daemon", "spider", "crawler",
|
|
134
|
+
"scanner", "sniffer", "honeypot", "firewall", "breach"
|
|
135
|
+
]
|
|
136
|
+
# fmt: on
|
|
137
|
+
adj = secrets.choice(adjectives)
|
|
138
|
+
noun = secrets.choice(nouns)
|
|
139
|
+
number = secrets.randbelow(900) + 100
|
|
140
|
+
return f"{adj}-{noun}-{number}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Target processing utilities
|
|
144
|
+
def infer_target_type(target: str) -> tuple[str, dict[str, str]]:
|
|
145
|
+
if not target or not isinstance(target, str):
|
|
146
|
+
raise ValueError("Target must be a non-empty string")
|
|
147
|
+
|
|
148
|
+
target = target.strip()
|
|
149
|
+
|
|
150
|
+
lower_target = target.lower()
|
|
151
|
+
bare_repo_prefixes = (
|
|
152
|
+
"github.com/",
|
|
153
|
+
"www.github.com/",
|
|
154
|
+
"gitlab.com/",
|
|
155
|
+
"www.gitlab.com/",
|
|
156
|
+
"bitbucket.org/",
|
|
157
|
+
"www.bitbucket.org/",
|
|
158
|
+
)
|
|
159
|
+
if any(lower_target.startswith(p) for p in bare_repo_prefixes):
|
|
160
|
+
return "repository", {"target_repo": f"https://{target}"}
|
|
161
|
+
|
|
162
|
+
parsed = urlparse(target)
|
|
163
|
+
if parsed.scheme in ("http", "https"):
|
|
164
|
+
if any(
|
|
165
|
+
host in parsed.netloc.lower() for host in ["github.com", "gitlab.com", "bitbucket.org"]
|
|
166
|
+
):
|
|
167
|
+
return "repository", {"target_repo": target}
|
|
168
|
+
return "web_application", {"target_url": target}
|
|
169
|
+
|
|
170
|
+
path = Path(target).expanduser()
|
|
171
|
+
try:
|
|
172
|
+
if path.exists():
|
|
173
|
+
if path.is_dir():
|
|
174
|
+
resolved = path.resolve()
|
|
175
|
+
return "local_code", {"target_path": str(resolved)}
|
|
176
|
+
raise ValueError(f"Path exists but is not a directory: {target}")
|
|
177
|
+
except (OSError, RuntimeError) as e:
|
|
178
|
+
raise ValueError(f"Invalid path: {target} - {e!s}") from e
|
|
179
|
+
|
|
180
|
+
if target.startswith("git@") or target.endswith(".git"):
|
|
181
|
+
return "repository", {"target_repo": target}
|
|
182
|
+
|
|
183
|
+
if "." in target and "/" not in target and not target.startswith("."):
|
|
184
|
+
parts = target.split(".")
|
|
185
|
+
if len(parts) >= 2 and all(p and p.strip() for p in parts):
|
|
186
|
+
return "web_application", {"target_url": f"https://{target}"}
|
|
187
|
+
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Invalid target: {target}\n"
|
|
190
|
+
"Target must be one of:\n"
|
|
191
|
+
"- A valid URL (http:// or https://)\n"
|
|
192
|
+
"- A Git repository URL (https://github.com/... or git@github.com:...)\n"
|
|
193
|
+
"- A local directory path\n"
|
|
194
|
+
"- A domain name (e.g., example.com)"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def sanitize_name(name: str) -> str:
|
|
199
|
+
sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", name.strip())
|
|
200
|
+
return sanitized or "target"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def derive_repo_base_name(repo_url: str) -> str:
|
|
204
|
+
if repo_url.endswith("/"):
|
|
205
|
+
repo_url = repo_url[:-1]
|
|
206
|
+
|
|
207
|
+
if ":" in repo_url and repo_url.startswith("git@"):
|
|
208
|
+
path_part = repo_url.split(":", 1)[1]
|
|
209
|
+
else:
|
|
210
|
+
path_part = urlparse(repo_url).path or repo_url
|
|
211
|
+
|
|
212
|
+
candidate = path_part.split("/")[-1]
|
|
213
|
+
if candidate.endswith(".git"):
|
|
214
|
+
candidate = candidate[:-4]
|
|
215
|
+
|
|
216
|
+
return sanitize_name(candidate or "repository")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def derive_local_base_name(path_str: str) -> str:
|
|
220
|
+
try:
|
|
221
|
+
base = Path(path_str).resolve().name
|
|
222
|
+
except (OSError, RuntimeError):
|
|
223
|
+
base = Path(path_str).name
|
|
224
|
+
return sanitize_name(base or "workspace")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def assign_workspace_subdirs(targets_info: list[dict[str, Any]]) -> None:
|
|
228
|
+
name_counts: dict[str, int] = {}
|
|
229
|
+
|
|
230
|
+
for target in targets_info:
|
|
231
|
+
target_type = target["type"]
|
|
232
|
+
details = target["details"]
|
|
233
|
+
|
|
234
|
+
base_name: str | None = None
|
|
235
|
+
if target_type == "repository":
|
|
236
|
+
base_name = derive_repo_base_name(details["target_repo"])
|
|
237
|
+
elif target_type == "local_code":
|
|
238
|
+
base_name = derive_local_base_name(details.get("target_path", "local"))
|
|
239
|
+
|
|
240
|
+
if base_name is None:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
count = name_counts.get(base_name, 0) + 1
|
|
244
|
+
name_counts[base_name] = count
|
|
245
|
+
|
|
246
|
+
workspace_subdir = base_name if count == 1 else f"{base_name}-{count}"
|
|
247
|
+
|
|
248
|
+
details["workspace_subdir"] = workspace_subdir
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
252
|
+
local_sources: list[dict[str, str]] = []
|
|
253
|
+
|
|
254
|
+
for target_info in targets_info:
|
|
255
|
+
details = target_info["details"]
|
|
256
|
+
workspace_subdir = details.get("workspace_subdir")
|
|
257
|
+
|
|
258
|
+
if target_info["type"] == "local_code" and "target_path" in details:
|
|
259
|
+
local_sources.append(
|
|
260
|
+
{
|
|
261
|
+
"source_path": details["target_path"],
|
|
262
|
+
"workspace_subdir": workspace_subdir,
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
elif target_info["type"] == "repository" and "cloned_repo_path" in details:
|
|
267
|
+
local_sources.append(
|
|
268
|
+
{
|
|
269
|
+
"source_path": details["cloned_repo_path"],
|
|
270
|
+
"workspace_subdir": workspace_subdir,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return local_sources
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Repository utilities
|
|
278
|
+
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
|
|
279
|
+
console = Console()
|
|
280
|
+
|
|
281
|
+
git_executable = shutil.which("git")
|
|
282
|
+
if git_executable is None:
|
|
283
|
+
raise FileNotFoundError("Git executable not found in PATH")
|
|
284
|
+
|
|
285
|
+
temp_dir = Path(tempfile.gettempdir()) / "strix_repos" / run_name
|
|
286
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
|
|
288
|
+
if dest_name:
|
|
289
|
+
repo_name = dest_name
|
|
290
|
+
else:
|
|
291
|
+
repo_name = Path(repo_url).stem if repo_url.endswith(".git") else Path(repo_url).name
|
|
292
|
+
|
|
293
|
+
clone_path = temp_dir / repo_name
|
|
294
|
+
|
|
295
|
+
if clone_path.exists():
|
|
296
|
+
shutil.rmtree(clone_path)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
with console.status(f"[bold cyan]Cloning repository {repo_url}...", spinner="dots"):
|
|
300
|
+
subprocess.run( # noqa: S603
|
|
301
|
+
[
|
|
302
|
+
git_executable,
|
|
303
|
+
"clone",
|
|
304
|
+
repo_url,
|
|
305
|
+
str(clone_path),
|
|
306
|
+
],
|
|
307
|
+
capture_output=True,
|
|
308
|
+
text=True,
|
|
309
|
+
check=True,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return str(clone_path.absolute())
|
|
313
|
+
|
|
314
|
+
except subprocess.CalledProcessError as e:
|
|
315
|
+
error_text = Text()
|
|
316
|
+
error_text.append("❌ ", style="bold red")
|
|
317
|
+
error_text.append("REPOSITORY CLONE FAILED", style="bold red")
|
|
318
|
+
error_text.append("\n\n", style="white")
|
|
319
|
+
error_text.append(f"Could not clone repository: {repo_url}\n", style="white")
|
|
320
|
+
error_text.append(
|
|
321
|
+
f"Error: {e.stderr if hasattr(e, 'stderr') and e.stderr else str(e)}", style="dim red"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
panel = Panel(
|
|
325
|
+
error_text,
|
|
326
|
+
title="[bold red]🛡️ STRIX CLONE ERROR",
|
|
327
|
+
title_align="center",
|
|
328
|
+
border_style="red",
|
|
329
|
+
padding=(1, 2),
|
|
330
|
+
)
|
|
331
|
+
console.print("\n")
|
|
332
|
+
console.print(panel)
|
|
333
|
+
console.print()
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
except FileNotFoundError:
|
|
336
|
+
error_text = Text()
|
|
337
|
+
error_text.append("❌ ", style="bold red")
|
|
338
|
+
error_text.append("GIT NOT FOUND", style="bold red")
|
|
339
|
+
error_text.append("\n\n", style="white")
|
|
340
|
+
error_text.append("Git is not installed or not available in PATH.\n", style="white")
|
|
341
|
+
error_text.append("Please install Git to clone repositories.\n", style="white")
|
|
342
|
+
|
|
343
|
+
panel = Panel(
|
|
344
|
+
error_text,
|
|
345
|
+
title="[bold red]🛡️ STRIX CLONE ERROR",
|
|
346
|
+
title_align="center",
|
|
347
|
+
border_style="red",
|
|
348
|
+
padding=(1, 2),
|
|
349
|
+
)
|
|
350
|
+
console.print("\n")
|
|
351
|
+
console.print(panel)
|
|
352
|
+
console.print()
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Docker utilities
|
|
357
|
+
def check_docker_connection() -> Any:
|
|
358
|
+
try:
|
|
359
|
+
return docker.from_env()
|
|
360
|
+
except DockerException:
|
|
361
|
+
console = Console()
|
|
362
|
+
error_text = Text()
|
|
363
|
+
error_text.append("❌ ", style="bold red")
|
|
364
|
+
error_text.append("DOCKER NOT AVAILABLE", style="bold red")
|
|
365
|
+
error_text.append("\n\n", style="white")
|
|
366
|
+
error_text.append("Cannot connect to Docker daemon.\n", style="white")
|
|
367
|
+
error_text.append("Please ensure Docker is installed and running.\n\n", style="white")
|
|
368
|
+
error_text.append("Try running: ", style="dim white")
|
|
369
|
+
error_text.append("sudo systemctl start docker", style="dim cyan")
|
|
370
|
+
|
|
371
|
+
panel = Panel(
|
|
372
|
+
error_text,
|
|
373
|
+
title="[bold red]🛡️ STRIX STARTUP ERROR",
|
|
374
|
+
title_align="center",
|
|
375
|
+
border_style="red",
|
|
376
|
+
padding=(1, 2),
|
|
377
|
+
)
|
|
378
|
+
console.print("\n", panel, "\n")
|
|
379
|
+
raise RuntimeError("Docker not available") from None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def image_exists(client: Any, image_name: str) -> bool:
|
|
383
|
+
try:
|
|
384
|
+
client.images.get(image_name)
|
|
385
|
+
except ImageNotFound:
|
|
386
|
+
return False
|
|
387
|
+
else:
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def update_layer_status(layers_info: dict[str, str], layer_id: str, layer_status: str) -> None:
|
|
392
|
+
if "Pull complete" in layer_status or "Already exists" in layer_status:
|
|
393
|
+
layers_info[layer_id] = "✓"
|
|
394
|
+
elif "Downloading" in layer_status:
|
|
395
|
+
layers_info[layer_id] = "↓"
|
|
396
|
+
elif "Extracting" in layer_status:
|
|
397
|
+
layers_info[layer_id] = "📦"
|
|
398
|
+
elif "Waiting" in layer_status:
|
|
399
|
+
layers_info[layer_id] = "⏳"
|
|
400
|
+
else:
|
|
401
|
+
layers_info[layer_id] = "•"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def process_pull_line(
|
|
405
|
+
line: dict[str, Any], layers_info: dict[str, str], status: Any, last_update: str
|
|
406
|
+
) -> str:
|
|
407
|
+
if "id" in line and "status" in line:
|
|
408
|
+
layer_id = line["id"]
|
|
409
|
+
update_layer_status(layers_info, layer_id, line["status"])
|
|
410
|
+
|
|
411
|
+
completed = sum(1 for v in layers_info.values() if v == "✓")
|
|
412
|
+
total = len(layers_info)
|
|
413
|
+
|
|
414
|
+
if total > 0:
|
|
415
|
+
update_msg = f"[bold cyan]Progress: {completed}/{total} layers complete"
|
|
416
|
+
if update_msg != last_update:
|
|
417
|
+
status.update(update_msg)
|
|
418
|
+
return update_msg
|
|
419
|
+
|
|
420
|
+
elif "status" in line and "id" not in line:
|
|
421
|
+
global_status = line["status"]
|
|
422
|
+
if "Pulling from" in global_status:
|
|
423
|
+
status.update("[bold cyan]Fetching image manifest...")
|
|
424
|
+
elif "Digest:" in global_status:
|
|
425
|
+
status.update("[bold cyan]Verifying image...")
|
|
426
|
+
elif "Status:" in global_status:
|
|
427
|
+
status.update("[bold cyan]Finalizing...")
|
|
428
|
+
|
|
429
|
+
return last_update
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# LLM utilities
|
|
433
|
+
def validate_llm_response(response: Any) -> None:
|
|
434
|
+
if not response or not response.choices or not response.choices[0].message.content:
|
|
435
|
+
raise RuntimeError("Invalid response from LLM")
|