strix-agent 0.4.0__py3-none-any.whl → 0.6.2__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.
- strix/agents/StrixAgent/strix_agent.py +3 -3
- strix/agents/StrixAgent/system_prompt.jinja +30 -26
- strix/agents/base_agent.py +159 -75
- strix/agents/state.py +5 -2
- strix/config/__init__.py +12 -0
- strix/config/config.py +172 -0
- strix/interface/assets/tui_styles.tcss +195 -230
- strix/interface/cli.py +16 -41
- strix/interface/main.py +151 -74
- strix/interface/streaming_parser.py +119 -0
- strix/interface/tool_components/__init__.py +4 -0
- strix/interface/tool_components/agent_message_renderer.py +190 -0
- strix/interface/tool_components/agents_graph_renderer.py +54 -38
- strix/interface/tool_components/base_renderer.py +68 -36
- strix/interface/tool_components/browser_renderer.py +106 -91
- strix/interface/tool_components/file_edit_renderer.py +117 -36
- strix/interface/tool_components/finish_renderer.py +43 -10
- strix/interface/tool_components/notes_renderer.py +63 -38
- strix/interface/tool_components/proxy_renderer.py +133 -92
- strix/interface/tool_components/python_renderer.py +121 -8
- strix/interface/tool_components/registry.py +19 -12
- strix/interface/tool_components/reporting_renderer.py +196 -28
- strix/interface/tool_components/scan_info_renderer.py +22 -19
- strix/interface/tool_components/terminal_renderer.py +270 -90
- strix/interface/tool_components/thinking_renderer.py +8 -6
- strix/interface/tool_components/todo_renderer.py +225 -0
- strix/interface/tool_components/user_message_renderer.py +26 -19
- strix/interface/tool_components/web_search_renderer.py +7 -6
- strix/interface/tui.py +907 -262
- strix/interface/utils.py +236 -4
- strix/llm/__init__.py +6 -2
- strix/llm/config.py +8 -5
- strix/llm/dedupe.py +217 -0
- strix/llm/llm.py +209 -356
- strix/llm/memory_compressor.py +6 -5
- strix/llm/utils.py +17 -8
- strix/runtime/__init__.py +12 -3
- strix/runtime/docker_runtime.py +121 -202
- strix/runtime/tool_server.py +55 -95
- strix/skills/README.md +64 -0
- strix/skills/__init__.py +110 -0
- strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
- strix/skills/scan_modes/deep.jinja +145 -0
- strix/skills/scan_modes/quick.jinja +63 -0
- strix/skills/scan_modes/standard.jinja +91 -0
- strix/telemetry/README.md +38 -0
- strix/telemetry/__init__.py +7 -1
- strix/telemetry/posthog.py +137 -0
- strix/telemetry/tracer.py +194 -54
- strix/tools/__init__.py +11 -4
- strix/tools/agents_graph/agents_graph_actions.py +20 -21
- strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
- strix/tools/browser/browser_actions.py +10 -6
- strix/tools/browser/browser_actions_schema.xml +6 -1
- strix/tools/browser/browser_instance.py +96 -48
- strix/tools/browser/tab_manager.py +121 -102
- strix/tools/context.py +12 -0
- strix/tools/executor.py +63 -4
- strix/tools/file_edit/file_edit_actions.py +6 -3
- strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
- strix/tools/finish/finish_actions.py +80 -105
- strix/tools/finish/finish_actions_schema.xml +121 -14
- strix/tools/notes/notes_actions.py +6 -33
- strix/tools/notes/notes_actions_schema.xml +50 -46
- strix/tools/proxy/proxy_actions.py +14 -2
- strix/tools/proxy/proxy_actions_schema.xml +0 -1
- strix/tools/proxy/proxy_manager.py +28 -16
- strix/tools/python/python_actions.py +2 -2
- strix/tools/python/python_actions_schema.xml +9 -1
- strix/tools/python/python_instance.py +39 -37
- strix/tools/python/python_manager.py +43 -31
- strix/tools/registry.py +73 -12
- strix/tools/reporting/reporting_actions.py +218 -31
- strix/tools/reporting/reporting_actions_schema.xml +256 -8
- strix/tools/terminal/terminal_actions.py +2 -2
- strix/tools/terminal/terminal_actions_schema.xml +6 -0
- strix/tools/terminal/terminal_manager.py +41 -30
- strix/tools/thinking/thinking_actions_schema.xml +27 -25
- strix/tools/todo/__init__.py +18 -0
- strix/tools/todo/todo_actions.py +568 -0
- strix/tools/todo/todo_actions_schema.xml +225 -0
- strix/utils/__init__.py +0 -0
- strix/utils/resource_paths.py +13 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
- strix_agent-0.6.2.dist-info/RECORD +134 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
- strix/llm/request_queue.py +0 -87
- strix/prompts/README.md +0 -64
- strix/prompts/__init__.py +0 -109
- strix_agent-0.4.0.dist-info/RECORD +0 -118
- /strix/{prompts → skills}/cloud/.gitkeep +0 -0
- /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
- /strix/{prompts → skills}/custom/.gitkeep +0 -0
- /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
- /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
- /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
- /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
- /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info/licenses}/LICENSE +0 -0
strix/llm/memory_compressor.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import os
|
|
3
2
|
from typing import Any
|
|
4
3
|
|
|
5
4
|
import litellm
|
|
6
5
|
|
|
6
|
+
from strix.config import Config
|
|
7
|
+
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
@@ -85,7 +86,7 @@ def _extract_message_text(msg: dict[str, Any]) -> str:
|
|
|
85
86
|
def _summarize_messages(
|
|
86
87
|
messages: list[dict[str, Any]],
|
|
87
88
|
model: str,
|
|
88
|
-
timeout: int =
|
|
89
|
+
timeout: int = 30,
|
|
89
90
|
) -> dict[str, Any]:
|
|
90
91
|
if not messages:
|
|
91
92
|
empty_summary = "<context_summary message_count='0'>{text}</context_summary>"
|
|
@@ -147,11 +148,11 @@ class MemoryCompressor:
|
|
|
147
148
|
self,
|
|
148
149
|
max_images: int = 3,
|
|
149
150
|
model_name: str | None = None,
|
|
150
|
-
timeout: int =
|
|
151
|
+
timeout: int | None = None,
|
|
151
152
|
):
|
|
152
153
|
self.max_images = max_images
|
|
153
|
-
self.model_name = model_name or
|
|
154
|
-
self.timeout = timeout
|
|
154
|
+
self.model_name = model_name or Config.get("strix_llm")
|
|
155
|
+
self.timeout = timeout or int(Config.get("strix_memory_compressor_timeout") or "30")
|
|
155
156
|
|
|
156
157
|
if not self.model_name:
|
|
157
158
|
raise ValueError("STRIX_LLM environment variable must be set and not empty")
|
strix/llm/utils.py
CHANGED
|
@@ -18,7 +18,7 @@ def _truncate_to_first_function(content: str) -> str:
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
|
|
21
|
-
content =
|
|
21
|
+
content = fix_incomplete_tool_call(content)
|
|
22
22
|
|
|
23
23
|
tool_invocations: list[dict[str, Any]] = []
|
|
24
24
|
|
|
@@ -46,12 +46,15 @@ def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
|
|
|
46
46
|
return tool_invocations if tool_invocations else None
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
def fix_incomplete_tool_call(content: str) -> str:
|
|
50
|
+
"""Fix incomplete tool calls by adding missing </function> tag."""
|
|
51
|
+
if (
|
|
52
|
+
"<function=" in content
|
|
53
|
+
and content.count("<function=") == 1
|
|
54
|
+
and "</function>" not in content
|
|
55
|
+
):
|
|
56
|
+
content = content.rstrip()
|
|
57
|
+
content = content + "function>" if content.endswith("</") else content + "\n</function>"
|
|
55
58
|
return content
|
|
56
59
|
|
|
57
60
|
|
|
@@ -70,11 +73,17 @@ def clean_content(content: str) -> str:
|
|
|
70
73
|
if not content:
|
|
71
74
|
return ""
|
|
72
75
|
|
|
73
|
-
content =
|
|
76
|
+
content = fix_incomplete_tool_call(content)
|
|
74
77
|
|
|
75
78
|
tool_pattern = r"<function=[^>]+>.*?</function>"
|
|
76
79
|
cleaned = re.sub(tool_pattern, "", content, flags=re.DOTALL)
|
|
77
80
|
|
|
81
|
+
incomplete_tool_pattern = r"<function=[^>]+>.*$"
|
|
82
|
+
cleaned = re.sub(incomplete_tool_pattern, "", cleaned, flags=re.DOTALL)
|
|
83
|
+
|
|
84
|
+
partial_tag_pattern = r"<f(?:u(?:n(?:c(?:t(?:i(?:o(?:n(?:=(?:[^>]*)?)?)?)?)?)?)?)?)?$"
|
|
85
|
+
cleaned = re.sub(partial_tag_pattern, "", cleaned)
|
|
86
|
+
|
|
78
87
|
hidden_xml_patterns = [
|
|
79
88
|
r"<inter_agent_message>.*?</inter_agent_message>",
|
|
80
89
|
r"<agent_completion_report>.*?</agent_completion_report>",
|
strix/runtime/__init__.py
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import
|
|
1
|
+
from strix.config import Config
|
|
2
2
|
|
|
3
3
|
from .runtime import AbstractRuntime
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
class SandboxInitializationError(Exception):
|
|
7
|
+
"""Raised when sandbox initialization fails (e.g., Docker issues)."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, details: str | None = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.details = details
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
def get_runtime() -> AbstractRuntime:
|
|
7
|
-
runtime_backend =
|
|
16
|
+
runtime_backend = Config.get("strix_runtime_backend")
|
|
8
17
|
|
|
9
18
|
if runtime_backend == "docker":
|
|
10
19
|
from .docker_runtime import DockerRuntime
|
|
@@ -16,4 +25,4 @@ def get_runtime() -> AbstractRuntime:
|
|
|
16
25
|
)
|
|
17
26
|
|
|
18
27
|
|
|
19
|
-
__all__ = ["AbstractRuntime", "get_runtime"]
|
|
28
|
+
__all__ = ["AbstractRuntime", "SandboxInitializationError", "get_runtime"]
|
strix/runtime/docker_runtime.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import contextlib
|
|
2
|
-
import logging
|
|
3
2
|
import os
|
|
4
3
|
import secrets
|
|
5
4
|
import socket
|
|
@@ -8,31 +7,37 @@ from pathlib import Path
|
|
|
8
7
|
from typing import cast
|
|
9
8
|
|
|
10
9
|
import docker
|
|
10
|
+
import httpx
|
|
11
11
|
from docker.errors import DockerException, ImageNotFound, NotFound
|
|
12
12
|
from docker.models.containers import Container
|
|
13
|
+
from requests.exceptions import ConnectionError as RequestsConnectionError
|
|
14
|
+
from requests.exceptions import Timeout as RequestsTimeout
|
|
13
15
|
|
|
16
|
+
from strix.config import Config
|
|
17
|
+
|
|
18
|
+
from . import SandboxInitializationError
|
|
14
19
|
from .runtime import AbstractRuntime, SandboxInfo
|
|
15
20
|
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
|
|
23
|
+
DOCKER_TIMEOUT = 60
|
|
24
|
+
CONTAINER_TOOL_SERVER_PORT = 48081
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
class DockerRuntime(AbstractRuntime):
|
|
22
28
|
def __init__(self) -> None:
|
|
23
29
|
try:
|
|
24
|
-
self.client = docker.from_env()
|
|
25
|
-
except DockerException as e:
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
self.client = docker.from_env(timeout=DOCKER_TIMEOUT)
|
|
31
|
+
except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
|
|
32
|
+
raise SandboxInitializationError(
|
|
33
|
+
"Docker is not available",
|
|
34
|
+
"Please ensure Docker Desktop is installed and running.",
|
|
35
|
+
) from e
|
|
28
36
|
|
|
29
37
|
self._scan_container: Container | None = None
|
|
30
38
|
self._tool_server_port: int | None = None
|
|
31
39
|
self._tool_server_token: str | None = None
|
|
32
40
|
|
|
33
|
-
def _generate_sandbox_token(self) -> str:
|
|
34
|
-
return secrets.token_urlsafe(32)
|
|
35
|
-
|
|
36
41
|
def _find_available_port(self) -> int:
|
|
37
42
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
38
43
|
s.bind(("", 0))
|
|
@@ -45,113 +50,118 @@ class DockerRuntime(AbstractRuntime):
|
|
|
45
50
|
tracer = get_global_tracer()
|
|
46
51
|
if tracer and tracer.scan_config:
|
|
47
52
|
return str(tracer.scan_config.get("scan_id", "default-scan"))
|
|
48
|
-
except ImportError:
|
|
49
|
-
|
|
50
|
-
except AttributeError:
|
|
51
|
-
logger.debug("Tracer missing scan_config, using fallback scan ID")
|
|
52
|
-
|
|
53
|
+
except (ImportError, AttributeError):
|
|
54
|
+
pass
|
|
53
55
|
return f"scan-{agent_id.split('-')[0]}"
|
|
54
56
|
|
|
55
57
|
def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:
|
|
56
|
-
def _validate_image(image: docker.models.images.Image) -> None:
|
|
57
|
-
if not image.id or not image.attrs:
|
|
58
|
-
raise ImageNotFound(f"Image {image_name} metadata incomplete")
|
|
59
|
-
|
|
60
58
|
for attempt in range(max_retries):
|
|
61
59
|
try:
|
|
62
60
|
image = self.client.images.get(image_name)
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
if not image.id or not image.attrs:
|
|
62
|
+
raise ImageNotFound(f"Image {image_name} metadata incomplete") # noqa: TRY301
|
|
63
|
+
except (ImageNotFound, DockerException):
|
|
65
64
|
if attempt == max_retries - 1:
|
|
66
|
-
logger.exception(f"Image {image_name} not found after {max_retries} attempts")
|
|
67
65
|
raise
|
|
68
|
-
logger.warning(f"Image {image_name} not ready, attempt {attempt + 1}/{max_retries}")
|
|
69
|
-
time.sleep(2**attempt)
|
|
70
|
-
except DockerException:
|
|
71
|
-
if attempt == max_retries - 1:
|
|
72
|
-
logger.exception(f"Failed to verify image {image_name}")
|
|
73
|
-
raise
|
|
74
|
-
logger.warning(f"Docker error verifying image, attempt {attempt + 1}/{max_retries}")
|
|
75
66
|
time.sleep(2**attempt)
|
|
76
67
|
else:
|
|
77
|
-
logger.debug(f"Image {image_name} verified as available")
|
|
78
68
|
return
|
|
79
69
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
def _recover_container_state(self, container: Container) -> None:
|
|
71
|
+
for env_var in container.attrs["Config"]["Env"]:
|
|
72
|
+
if env_var.startswith("TOOL_SERVER_TOKEN="):
|
|
73
|
+
self._tool_server_token = env_var.split("=", 1)[1]
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {})
|
|
77
|
+
port_key = f"{CONTAINER_TOOL_SERVER_PORT}/tcp"
|
|
78
|
+
if port_bindings.get(port_key):
|
|
79
|
+
self._tool_server_port = int(port_bindings[port_key][0]["HostPort"])
|
|
80
|
+
|
|
81
|
+
def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None:
|
|
82
|
+
host = self._resolve_docker_host()
|
|
83
|
+
health_url = f"http://{host}:{self._tool_server_port}/health"
|
|
84
|
+
|
|
85
|
+
time.sleep(5)
|
|
83
86
|
|
|
84
87
|
for attempt in range(max_retries):
|
|
85
88
|
try:
|
|
86
|
-
|
|
89
|
+
with httpx.Client(trust_env=False, timeout=timeout) as client:
|
|
90
|
+
response = client.get(health_url)
|
|
91
|
+
if response.status_code == 200:
|
|
92
|
+
data = response.json()
|
|
93
|
+
if data.get("status") == "healthy":
|
|
94
|
+
return
|
|
95
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
time.sleep(min(2**attempt * 0.5, 5))
|
|
99
|
+
|
|
100
|
+
raise SandboxInitializationError(
|
|
101
|
+
"Tool server failed to start",
|
|
102
|
+
"Container initialization timed out. Please try again.",
|
|
103
|
+
)
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
105
|
+
def _create_container(self, scan_id: str, max_retries: int = 2) -> Container:
|
|
106
|
+
container_name = f"strix-scan-{scan_id}"
|
|
107
|
+
image_name = Config.get("strix_image")
|
|
108
|
+
if not image_name:
|
|
109
|
+
raise ValueError("STRIX_IMAGE must be configured")
|
|
110
|
+
|
|
111
|
+
self._verify_image_available(image_name)
|
|
112
|
+
|
|
113
|
+
last_error: Exception | None = None
|
|
114
|
+
for attempt in range(max_retries + 1):
|
|
115
|
+
try:
|
|
116
|
+
with contextlib.suppress(NotFound):
|
|
117
|
+
existing = self.client.containers.get(container_name)
|
|
91
118
|
with contextlib.suppress(Exception):
|
|
92
|
-
|
|
93
|
-
|
|
119
|
+
existing.stop(timeout=5)
|
|
120
|
+
existing.remove(force=True)
|
|
94
121
|
time.sleep(1)
|
|
95
|
-
except NotFound:
|
|
96
|
-
pass
|
|
97
|
-
except DockerException as e:
|
|
98
|
-
logger.warning(f"Error checking/removing existing container: {e}")
|
|
99
|
-
|
|
100
|
-
caido_port = self._find_available_port()
|
|
101
|
-
tool_server_port = self._find_available_port()
|
|
102
|
-
tool_server_token = self._generate_sandbox_token()
|
|
103
122
|
|
|
104
|
-
self._tool_server_port =
|
|
105
|
-
self._tool_server_token =
|
|
123
|
+
self._tool_server_port = self._find_available_port()
|
|
124
|
+
self._tool_server_token = secrets.token_urlsafe(32)
|
|
125
|
+
execution_timeout = Config.get("strix_sandbox_execution_timeout") or "120"
|
|
106
126
|
|
|
107
127
|
container = self.client.containers.run(
|
|
108
|
-
|
|
128
|
+
image_name,
|
|
109
129
|
command="sleep infinity",
|
|
110
130
|
detach=True,
|
|
111
131
|
name=container_name,
|
|
112
|
-
hostname=
|
|
113
|
-
ports={
|
|
114
|
-
f"{caido_port}/tcp": caido_port,
|
|
115
|
-
f"{tool_server_port}/tcp": tool_server_port,
|
|
116
|
-
},
|
|
132
|
+
hostname=container_name,
|
|
133
|
+
ports={f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port},
|
|
117
134
|
cap_add=["NET_ADMIN", "NET_RAW"],
|
|
118
135
|
labels={"strix-scan-id": scan_id},
|
|
119
136
|
environment={
|
|
120
137
|
"PYTHONUNBUFFERED": "1",
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
138
|
+
"TOOL_SERVER_PORT": str(CONTAINER_TOOL_SERVER_PORT),
|
|
139
|
+
"TOOL_SERVER_TOKEN": self._tool_server_token,
|
|
140
|
+
"STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout),
|
|
141
|
+
"HOST_GATEWAY": HOST_GATEWAY_HOSTNAME,
|
|
124
142
|
},
|
|
143
|
+
extra_hosts={HOST_GATEWAY_HOSTNAME: "host-gateway"},
|
|
125
144
|
tty=True,
|
|
126
145
|
)
|
|
127
146
|
|
|
128
147
|
self._scan_container = container
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
logger.exception(f"Failed to create container after {max_retries} attempts")
|
|
138
|
-
break
|
|
139
|
-
|
|
140
|
-
logger.warning(f"Container creation attempt {attempt + 1}/{max_retries} failed")
|
|
141
|
-
|
|
142
|
-
self._tool_server_port = None
|
|
143
|
-
self._tool_server_token = None
|
|
144
|
-
|
|
145
|
-
sleep_time = (2**attempt) + (0.1 * attempt)
|
|
146
|
-
time.sleep(sleep_time)
|
|
148
|
+
self._wait_for_tool_server()
|
|
149
|
+
|
|
150
|
+
except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
|
|
151
|
+
last_error = e
|
|
152
|
+
if attempt < max_retries:
|
|
153
|
+
self._tool_server_port = None
|
|
154
|
+
self._tool_server_token = None
|
|
155
|
+
time.sleep(2**attempt)
|
|
147
156
|
else:
|
|
148
157
|
return container
|
|
149
158
|
|
|
150
|
-
raise
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
raise SandboxInitializationError(
|
|
160
|
+
"Failed to create container",
|
|
161
|
+
f"Container creation failed after {max_retries + 1} attempts: {last_error}",
|
|
162
|
+
) from last_error
|
|
153
163
|
|
|
154
|
-
def
|
|
164
|
+
def _get_or_create_container(self, scan_id: str) -> Container:
|
|
155
165
|
container_name = f"strix-scan-{scan_id}"
|
|
156
166
|
|
|
157
167
|
if self._scan_container:
|
|
@@ -168,33 +178,14 @@ class DockerRuntime(AbstractRuntime):
|
|
|
168
178
|
container = self.client.containers.get(container_name)
|
|
169
179
|
container.reload()
|
|
170
180
|
|
|
171
|
-
if (
|
|
172
|
-
"strix-scan-id" not in container.labels
|
|
173
|
-
or container.labels["strix-scan-id"] != scan_id
|
|
174
|
-
):
|
|
175
|
-
logger.warning(
|
|
176
|
-
f"Container {container_name} exists but missing/wrong label, updating"
|
|
177
|
-
)
|
|
178
|
-
|
|
179
181
|
if container.status != "running":
|
|
180
|
-
logger.info(f"Starting existing container {container_name}")
|
|
181
182
|
container.start()
|
|
182
183
|
time.sleep(2)
|
|
183
184
|
|
|
184
185
|
self._scan_container = container
|
|
185
|
-
|
|
186
|
-
for env_var in container.attrs["Config"]["Env"]:
|
|
187
|
-
if env_var.startswith("TOOL_SERVER_PORT="):
|
|
188
|
-
self._tool_server_port = int(env_var.split("=")[1])
|
|
189
|
-
elif env_var.startswith("TOOL_SERVER_TOKEN="):
|
|
190
|
-
self._tool_server_token = env_var.split("=")[1]
|
|
191
|
-
|
|
192
|
-
logger.info(f"Reusing existing container {container_name}")
|
|
193
|
-
|
|
186
|
+
self._recover_container_state(container)
|
|
194
187
|
except NotFound:
|
|
195
188
|
pass
|
|
196
|
-
except DockerException as e:
|
|
197
|
-
logger.warning(f"Failed to get container by name {container_name}: {e}")
|
|
198
189
|
else:
|
|
199
190
|
return container
|
|
200
191
|
|
|
@@ -203,52 +194,18 @@ class DockerRuntime(AbstractRuntime):
|
|
|
203
194
|
all=True, filters={"label": f"strix-scan-id={scan_id}"}
|
|
204
195
|
)
|
|
205
196
|
if containers:
|
|
206
|
-
container =
|
|
197
|
+
container = containers[0]
|
|
207
198
|
if container.status != "running":
|
|
208
199
|
container.start()
|
|
209
200
|
time.sleep(2)
|
|
210
|
-
self._scan_container = container
|
|
211
|
-
|
|
212
|
-
for env_var in container.attrs["Config"]["Env"]:
|
|
213
|
-
if env_var.startswith("TOOL_SERVER_PORT="):
|
|
214
|
-
self._tool_server_port = int(env_var.split("=")[1])
|
|
215
|
-
elif env_var.startswith("TOOL_SERVER_TOKEN="):
|
|
216
|
-
self._tool_server_token = env_var.split("=")[1]
|
|
217
201
|
|
|
218
|
-
|
|
202
|
+
self._scan_container = container
|
|
203
|
+
self._recover_container_state(container)
|
|
219
204
|
return container
|
|
220
|
-
except DockerException
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
logger.info("Creating new Docker container for scan %s", scan_id)
|
|
224
|
-
return self._create_container_with_retry(scan_id)
|
|
225
|
-
|
|
226
|
-
def _initialize_container(
|
|
227
|
-
self, container: Container, caido_port: int, tool_server_port: int, tool_server_token: str
|
|
228
|
-
) -> None:
|
|
229
|
-
logger.info("Initializing Caido proxy on port %s", caido_port)
|
|
230
|
-
result = container.exec_run(
|
|
231
|
-
f"bash -c 'export CAIDO_PORT={caido_port} && /usr/local/bin/docker-entrypoint.sh true'",
|
|
232
|
-
detach=False,
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
time.sleep(5)
|
|
236
|
-
|
|
237
|
-
result = container.exec_run(
|
|
238
|
-
"bash -c 'source /etc/profile.d/proxy.sh && echo $CAIDO_API_TOKEN'", user="pentester"
|
|
239
|
-
)
|
|
240
|
-
caido_token = result.output.decode().strip() if result.exit_code == 0 else ""
|
|
241
|
-
|
|
242
|
-
container.exec_run(
|
|
243
|
-
f"bash -c 'source /etc/profile.d/proxy.sh && cd /app && "
|
|
244
|
-
f"STRIX_SANDBOX_MODE=true CAIDO_API_TOKEN={caido_token} CAIDO_PORT={caido_port} "
|
|
245
|
-
f"poetry run python strix/runtime/tool_server.py --token {tool_server_token} "
|
|
246
|
-
f"--host 0.0.0.0 --port {tool_server_port} &'",
|
|
247
|
-
detach=True,
|
|
248
|
-
user="pentester",
|
|
249
|
-
)
|
|
205
|
+
except DockerException:
|
|
206
|
+
pass
|
|
250
207
|
|
|
251
|
-
|
|
208
|
+
return self._create_container(scan_id)
|
|
252
209
|
|
|
253
210
|
def _copy_local_directory_to_container(
|
|
254
211
|
self, container: Container, local_path: str, target_name: str | None = None
|
|
@@ -259,17 +216,8 @@ class DockerRuntime(AbstractRuntime):
|
|
|
259
216
|
try:
|
|
260
217
|
local_path_obj = Path(local_path).resolve()
|
|
261
218
|
if not local_path_obj.exists() or not local_path_obj.is_dir():
|
|
262
|
-
logger.warning(f"Local path does not exist or is not directory: {local_path_obj}")
|
|
263
219
|
return
|
|
264
220
|
|
|
265
|
-
if target_name:
|
|
266
|
-
logger.info(
|
|
267
|
-
f"Copying local directory {local_path_obj} to container at "
|
|
268
|
-
f"/workspace/{target_name}"
|
|
269
|
-
)
|
|
270
|
-
else:
|
|
271
|
-
logger.info(f"Copying local directory {local_path_obj} to container")
|
|
272
|
-
|
|
273
221
|
tar_buffer = BytesIO()
|
|
274
222
|
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
|
275
223
|
for item in local_path_obj.rglob("*"):
|
|
@@ -280,16 +228,12 @@ class DockerRuntime(AbstractRuntime):
|
|
|
280
228
|
|
|
281
229
|
tar_buffer.seek(0)
|
|
282
230
|
container.put_archive("/workspace", tar_buffer.getvalue())
|
|
283
|
-
|
|
284
231
|
container.exec_run(
|
|
285
232
|
"chown -R pentester:pentester /workspace && chmod -R 755 /workspace",
|
|
286
233
|
user="root",
|
|
287
234
|
)
|
|
288
|
-
|
|
289
|
-
logger.info("Successfully copied local directory to /workspace")
|
|
290
|
-
|
|
291
235
|
except (OSError, DockerException):
|
|
292
|
-
|
|
236
|
+
pass
|
|
293
237
|
|
|
294
238
|
async def create_sandbox(
|
|
295
239
|
self,
|
|
@@ -298,7 +242,7 @@ class DockerRuntime(AbstractRuntime):
|
|
|
298
242
|
local_sources: list[dict[str, str]] | None = None,
|
|
299
243
|
) -> SandboxInfo:
|
|
300
244
|
scan_id = self._get_scan_id(agent_id)
|
|
301
|
-
container = self.
|
|
245
|
+
container = self._get_or_create_container(scan_id)
|
|
302
246
|
|
|
303
247
|
source_copied_key = f"_source_copied_{scan_id}"
|
|
304
248
|
if local_sources and not hasattr(self, source_copied_key):
|
|
@@ -306,40 +250,33 @@ class DockerRuntime(AbstractRuntime):
|
|
|
306
250
|
source_path = source.get("source_path")
|
|
307
251
|
if not source_path:
|
|
308
252
|
continue
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
target_name = Path(source_path).name or f"target_{index}"
|
|
313
|
-
|
|
253
|
+
target_name = (
|
|
254
|
+
source.get("workspace_subdir") or Path(source_path).name or f"target_{index}"
|
|
255
|
+
)
|
|
314
256
|
self._copy_local_directory_to_container(container, source_path, target_name)
|
|
315
257
|
setattr(self, source_copied_key, True)
|
|
316
258
|
|
|
317
|
-
|
|
318
|
-
if container_id is None:
|
|
259
|
+
if container.id is None:
|
|
319
260
|
raise RuntimeError("Docker container ID is unexpectedly None")
|
|
320
261
|
|
|
321
|
-
token = existing_token
|
|
322
|
-
|
|
262
|
+
token = existing_token or self._tool_server_token
|
|
323
263
|
if self._tool_server_port is None or token is None:
|
|
324
|
-
raise RuntimeError("Tool server not initialized
|
|
264
|
+
raise RuntimeError("Tool server not initialized")
|
|
325
265
|
|
|
326
|
-
|
|
266
|
+
host = self._resolve_docker_host()
|
|
267
|
+
api_url = f"http://{host}:{self._tool_server_port}"
|
|
327
268
|
|
|
328
|
-
await self.
|
|
269
|
+
await self._register_agent(api_url, agent_id, token)
|
|
329
270
|
|
|
330
271
|
return {
|
|
331
|
-
"workspace_id":
|
|
272
|
+
"workspace_id": container.id,
|
|
332
273
|
"api_url": api_url,
|
|
333
274
|
"auth_token": token,
|
|
334
275
|
"tool_server_port": self._tool_server_port,
|
|
335
276
|
"agent_id": agent_id,
|
|
336
277
|
}
|
|
337
278
|
|
|
338
|
-
async def
|
|
339
|
-
self, api_url: str, agent_id: str, token: str
|
|
340
|
-
) -> None:
|
|
341
|
-
import httpx
|
|
342
|
-
|
|
279
|
+
async def _register_agent(self, api_url: str, agent_id: str, token: str) -> None:
|
|
343
280
|
try:
|
|
344
281
|
async with httpx.AsyncClient(trust_env=False) as client:
|
|
345
282
|
response = await client.post(
|
|
@@ -349,51 +286,33 @@ class DockerRuntime(AbstractRuntime):
|
|
|
349
286
|
timeout=30,
|
|
350
287
|
)
|
|
351
288
|
response.raise_for_status()
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
logger.warning(f"Failed to register agent {agent_id}: {e}")
|
|
289
|
+
except httpx.RequestError:
|
|
290
|
+
pass
|
|
355
291
|
|
|
356
292
|
async def get_sandbox_url(self, container_id: str, port: int) -> str:
|
|
357
293
|
try:
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
host = self._resolve_docker_host()
|
|
362
|
-
|
|
294
|
+
self.client.containers.get(container_id)
|
|
295
|
+
return f"http://{self._resolve_docker_host()}:{port}"
|
|
363
296
|
except NotFound:
|
|
364
297
|
raise ValueError(f"Container {container_id} not found.") from None
|
|
365
|
-
except DockerException as e:
|
|
366
|
-
raise RuntimeError(f"Failed to get container URL for {container_id}: {e}") from e
|
|
367
|
-
else:
|
|
368
|
-
return f"http://{host}:{port}"
|
|
369
298
|
|
|
370
299
|
def _resolve_docker_host(self) -> str:
|
|
371
300
|
docker_host = os.getenv("DOCKER_HOST", "")
|
|
372
|
-
if
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
from urllib.parse import urlparse
|
|
376
|
-
|
|
377
|
-
parsed = urlparse(docker_host)
|
|
378
|
-
|
|
379
|
-
if parsed.scheme in ("tcp", "http", "https") and parsed.hostname:
|
|
380
|
-
return parsed.hostname
|
|
301
|
+
if docker_host:
|
|
302
|
+
from urllib.parse import urlparse
|
|
381
303
|
|
|
304
|
+
parsed = urlparse(docker_host)
|
|
305
|
+
if parsed.scheme in ("tcp", "http", "https") and parsed.hostname:
|
|
306
|
+
return parsed.hostname
|
|
382
307
|
return "127.0.0.1"
|
|
383
308
|
|
|
384
309
|
async def destroy_sandbox(self, container_id: str) -> None:
|
|
385
|
-
logger.info("Destroying scan container %s", container_id)
|
|
386
310
|
try:
|
|
387
311
|
container = self.client.containers.get(container_id)
|
|
388
312
|
container.stop()
|
|
389
313
|
container.remove()
|
|
390
|
-
logger.info("Successfully destroyed container %s", container_id)
|
|
391
|
-
|
|
392
314
|
self._scan_container = None
|
|
393
315
|
self._tool_server_port = None
|
|
394
316
|
self._tool_server_token = None
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
logger.warning("Container %s not found for destruction.", container_id)
|
|
398
|
-
except DockerException as e:
|
|
399
|
-
logger.warning("Failed to destroy container %s: %s", container_id, e)
|
|
317
|
+
except (NotFound, DockerException):
|
|
318
|
+
pass
|