Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions services/orchestrator/claude_code_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import json
import os
import subprocess
import subprocess # nosec B404 -- used only with static executable names + arg lists and shell=False (see _run_tests)
import tempfile
import time
from pathlib import Path
Expand Down Expand Up @@ -440,7 +440,7 @@ def _run_tests(self, tmpdir: str, language: str) -> Optional[Dict[str, Any]]:
try:
if language == "python":
# Try to run pytest
result = subprocess.run(
result = subprocess.run( # nosec B603 B607 -- shell=False, static argv; runs pytest on generated code inside an isolated tmpdir
["python", "-m", "pytest", tmpdir, "-v"],
capture_output=True,
text=True,
Expand All @@ -458,7 +458,7 @@ def _run_tests(self, tmpdir: str, language: str) -> Optional[Dict[str, Any]]:
# Try to run with node
test_files = [f for f in os.listdir(tmpdir) if f.startswith("test_")]
if test_files:
result = subprocess.run(
result = subprocess.run( # nosec B603 B607 -- shell=False, static argv; runs a generated test file inside an isolated tmpdir
["node", test_files[0]],
capture_output=True,
text=True,
Expand Down
2 changes: 1 addition & 1 deletion services/orchestrator/claude_sdk_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import logging
import os
import re
import subprocess
import subprocess # nosec B404 -- used with asyncio.create_subprocess_exec (shell=False) and a static arg list
import time
from dataclasses import dataclass
from datetime import datetime
Expand Down
10 changes: 5 additions & 5 deletions services/orchestrator/container_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,16 +534,16 @@ async def _get_container_status(self, container) -> ContainerStatus:
started = datetime.fromisoformat(
state["StartedAt"].replace("Z", "+00:00")
)
except:
pass
except (ValueError, TypeError):
logger.debug("Could not parse container StartedAt timestamp")

if state.get("FinishedAt"):
try:
finished = datetime.fromisoformat(
state["FinishedAt"].replace("Z", "+00:00")
)
except:
pass
except (ValueError, TypeError):
logger.debug("Could not parse container FinishedAt timestamp")

# Get resource usage (if available)
cpu_usage = None
Expand Down Expand Up @@ -586,7 +586,7 @@ async def _get_container_status(self, container) -> ContainerStatus:
if host_bindings:
for binding in host_bindings:
ports[container_port] = (
f"{binding.get('HostIp', '0.0.0.0')}:{binding.get('HostPort')}"
f"{binding.get('HostIp', '0.0.0.0')}:{binding.get('HostPort')}" # nosec B104 -- not a bind; formats the HostIp Docker already reported for a port mapping ('0.0.0.0' is only a display default)
)

# Get health status
Expand Down
46 changes: 36 additions & 10 deletions services/orchestrator/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,16 +120,27 @@ async def get_organization(org_id: str) -> Optional[Dict[str, Any]]:
@staticmethod
async def update_organization(org_id: str, **kwargs) -> bool:
"""Update organization"""
# Strict allowlist of columns that may be updated. Column identifiers
# cannot be bound as query params, so validate them against this fixed
# set (fail-fast, before opening a connection) to prevent SQL injection
# via **kwargs keys.
allowed_columns = {"name", "description", "settings"}
updates = {k: v for k, v in kwargs.items() if v is not None}
invalid = set(updates) - allowed_columns
if invalid:
raise ValueError(
f"Invalid column(s) for organizations update: {sorted(invalid)}"
)

async with get_db_connection() as conn:
set_clauses = []
params = []
param_count = 1

for key, value in kwargs.items():
if value is not None:
set_clauses.append(f"{key} = ${param_count}")
params.append(value)
param_count += 1
for key, value in updates.items():
set_clauses.append(f"{key} = ${param_count}")
params.append(value)
param_count += 1

if not set_clauses:
return False
Expand Down Expand Up @@ -238,16 +249,31 @@ async def get_team(team_id: str) -> Optional[Dict[str, Any]]:
@staticmethod
async def update_team(team_id: str, **kwargs) -> bool:
"""Update team"""
# Strict allowlist of columns that may be updated. Column identifiers
# cannot be bound as query params, so validate them against this fixed
# set (fail-fast, before opening a connection) to prevent SQL injection
# via **kwargs keys.
allowed_columns = {
"organization_id",
"name",
"description",
"team_type",
"settings",
}
updates = {k: v for k, v in kwargs.items() if v is not None}
invalid = set(updates) - allowed_columns
if invalid:
raise ValueError(f"Invalid column(s) for teams update: {sorted(invalid)}")

async with get_db_connection() as conn:
set_clauses = []
params = []
param_count = 1

for key, value in kwargs.items():
if value is not None:
set_clauses.append(f"{key} = ${param_count}")
params.append(value)
param_count += 1
for key, value in updates.items():
set_clauses.append(f"{key} = ${param_count}")
params.append(value)
param_count += 1

if not set_clauses:
return False
Expand Down
2 changes: 1 addition & 1 deletion services/orchestrator/git_workflow_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import json
import logging
import os
import subprocess
import subprocess # nosec B404 -- used for CompletedProcess/CalledProcessError types and create_subprocess_exec (shell=False) with static arg lists
import tempfile
from dataclasses import dataclass
from datetime import datetime
Expand Down
18 changes: 10 additions & 8 deletions services/orchestrator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5343,13 +5343,13 @@ async def stream_agent_container_logs(websocket: WebSocket, agent_id: str):
logger.error(f"Error in log stream for agent {agent_id}: {e}")
try:
await websocket.send_json({"error": str(e)})
except:
pass
except Exception:
logger.debug("Failed to send error frame on closing websocket")
finally:
try:
await websocket.close()
except:
pass
except Exception:
logger.debug("Failed to close websocket cleanly")


# ============================================================================
Expand Down Expand Up @@ -5695,8 +5695,9 @@ async def websocket_agent_conversation(
for conn in active_conversations.get(conversation_id, []):
try:
await conn.send_json(message_data)
except:
pass # Connection might be closed
except Exception:
# Connection might be closed; skip this subscriber
logger.debug("Skipped broadcast to a closed websocket")

# TODO: Here we would trigger agent response generation
# For now, send a simple acknowledgment after a delay
Expand All @@ -5717,8 +5718,9 @@ async def websocket_agent_conversation(
for conn in active_conversations.get(conversation_id, []):
try:
await conn.send_json(agent_response)
except:
pass
except Exception:
# Connection might be closed; skip this subscriber
logger.debug("Skipped broadcast to a closed websocket")

# Store agent response in database
async with get_db_connection() as conn:
Expand Down
6 changes: 5 additions & 1 deletion services/orchestrator/main_with_hierarchy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1409,4 +1409,8 @@ async def demo_endpoint():
if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=8000)
uvicorn.run(
app,
host=os.getenv("HOST", "0.0.0.0"), # nosec B104 -- container service binds all interfaces by default; override via HOST env
port=int(os.getenv("PORT", "8000")),
)
6 changes: 5 additions & 1 deletion services/orchestrator/model_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import json
import logging
import os
import tempfile
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
Expand Down Expand Up @@ -125,7 +126,10 @@ def __init__(self):

def _get_or_create_encryption_key(self) -> bytes:
"""Get or create encryption key for API credentials"""
key_file = "/tmp/fuzeagent_encryption.key"
# Avoid a hard-coded world-readable /tmp path; allow override and fall
# back to the platform temp dir (still /tmp inside the Linux container).
key_dir = os.getenv("FUZEAGENT_KEY_DIR", tempfile.gettempdir())
key_file = os.path.join(key_dir, "fuzeagent_encryption.key")

if os.path.exists(key_file):
with open(key_file, "rb") as f:
Expand Down
2 changes: 1 addition & 1 deletion services/orchestrator/sandbox_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ async def _create_container(self, sandbox: Sandbox, config: SandboxConfig):
"cap_drop": config.capabilities["drop"],
"cap_add": config.capabilities["add"],
"read_only": False, # Need write access for development
"tmpfs": {"/tmp": "rw,noexec,nosuid,size=1g"},
"tmpfs": {"/tmp": "rw,noexec,nosuid,size=1g"}, # nosec B108 -- Docker tmpfs mount point inside the sandbox container, hardened with noexec,nosuid
"labels": {
"fuzeagent.sandbox": "true",
"fuzeagent.agent_id": sandbox.agent_id,
Expand Down
6 changes: 5 additions & 1 deletion services/orchestrator/simple_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,8 @@ async def get_agent_documents(agent_id: str):
if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=8000)
uvicorn.run(
app,
host=os.getenv("HOST", "0.0.0.0"), # nosec B104 -- container service binds all interfaces by default; override via HOST env
port=int(os.getenv("PORT", "8000")),
)
2 changes: 1 addition & 1 deletion services/orchestrator/tests/test_auth_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

# Configure verification material before importing the auth module so that
# get_current_user runs in its prod-like (fail-closed) mode.
os.environ["JWT_SECRET"] = "test-secret-for-issue-6-authz"
os.environ["JWT_SECRET"] = "test-secret-for-issue-6-authz" # nosec B105 -- test-only JWT secret fixture, not a real credential
os.environ["JWT_ALGORITHM"] = "HS256"
os.environ.pop("AUTH_DISABLED", None)
os.environ.pop("JWT_AUDIENCE", None)
Expand Down
4 changes: 2 additions & 2 deletions services/orchestrator/tests/test_claude_code_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ def test_wrapper_initialization(self, mock_anthropic_client):
def test_constructor_accepts_agent_context(self, mock_anthropic_client):
"""Optional agent/task/workspace context is stored on the instance."""
wrapper = ClaudeCodeWrapper(
workspace_path="/tmp/does-not-need-to-exist",
workspace_path="/tmp/does-not-need-to-exist", # nosec B108 -- test-only literal path, never created/written
agent_id="agent-123",
task_id="task-456",
)

assert wrapper.workspace_path == "/tmp/does-not-need-to-exist"
assert wrapper.workspace_path == "/tmp/does-not-need-to-exist" # nosec B108 -- test-only literal path assertion
assert wrapper.agent_id == "agent-123"
assert wrapper.task_id == "task-456"
# repository_context is initialised as a fresh dict per instance.
Expand Down
89 changes: 89 additions & 0 deletions services/orchestrator/tests/test_database_update_allowlist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Unit tests for the column allowlist on DatabaseManager update methods.

These guard the B608 fix (issue #83): ``update_organization`` / ``update_team``
interpolate column identifiers (which cannot be bound as query params), so the
identifiers MUST be validated against a fixed allowlist. A rejected column must
fail fast — before any DB connection is opened — and a valid update must only
ever emit allowlisted column identifiers with every value bound as a $N param.
"""

from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch

import pytest

import database
from database import DatabaseManager


def _fake_db_connection(mock_conn):
@asynccontextmanager
async def _cm():
yield mock_conn

return _cm


@pytest.mark.asyncio
@pytest.mark.database
class TestUpdateColumnAllowlist:
async def test_update_organization_rejects_unknown_column(self):
# A non-allowlisted key (here even carrying a SQL payload) must be
# rejected with ValueError and must never reach the database.
with patch.object(
database, "get_db_connection", side_effect=AssertionError("connected!")
):
with pytest.raises(ValueError):
await DatabaseManager.update_organization(
"org-1", **{"name; DROP TABLE organizations; --": "x"}
)

async def test_update_team_rejects_unknown_column(self):
with patch.object(
database, "get_db_connection", side_effect=AssertionError("connected!")
):
with pytest.raises(ValueError):
await DatabaseManager.update_team("team-1", evil_column="x")

async def test_update_organization_valid_columns_are_parameterized(self):
mock_conn = AsyncMock()
mock_conn.execute = AsyncMock(return_value="UPDATE 1")

with patch.object(
database, "get_db_connection", _fake_db_connection(mock_conn)
):
ok = await DatabaseManager.update_organization(
"org-1", name="New Name", description="Desc"
)

assert ok is True
mock_conn.execute.assert_awaited_once()
query, *params = mock_conn.execute.await_args.args

# Only allowlisted identifiers appear; values are bound, not inlined.
assert "name = $1" in query
assert "description = $2" in query
assert "updated_at = $3" in query
assert "WHERE id = $4" in query
assert "New Name" not in query # value is a param, never in SQL text
assert params[0] == "New Name"
assert params[1] == "Desc"
assert params[-1] == "org-1"

async def test_update_team_valid_columns_are_parameterized(self):
mock_conn = AsyncMock()
mock_conn.execute = AsyncMock(return_value="UPDATE 1")

with patch.object(
database, "get_db_connection", _fake_db_connection(mock_conn)
):
ok = await DatabaseManager.update_team(
"team-1", name="Squad", team_type="engineering"
)

assert ok is True
query, *params = mock_conn.execute.await_args.args
assert "name = $1" in query
assert "team_type = $2" in query
assert "Squad" not in query
assert params[-1] == "team-1"
2 changes: 1 addition & 1 deletion services/orchestrator/tests/test_hierarchy_ws_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# Environment — set BEFORE importing auth/hierarchy_endpoints so the module
# evaluates with the correct JWT config (fail-closed, no bypass).
# ---------------------------------------------------------------------------
os.environ["JWT_SECRET"] = "test-secret-hierarchy-ws-authz"
os.environ["JWT_SECRET"] = "test-secret-hierarchy-ws-authz" # nosec B105 -- test-only JWT secret fixture, not a real credential
os.environ["JWT_ALGORITHM"] = "HS256"
os.environ.pop("AUTH_DISABLED", None)
os.environ.pop("JWT_AUDIENCE", None)
Expand Down
2 changes: 1 addition & 1 deletion services/orchestrator/tests/test_residual_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import pytest

# Configure verification material BEFORE importing auth so it runs fail-closed.
os.environ["JWT_SECRET"] = "test-secret-for-issue-6-authz"
os.environ["JWT_SECRET"] = "test-secret-for-issue-6-authz" # nosec B105 -- test-only JWT secret fixture, not a real credential
os.environ["JWT_ALGORITHM"] = "HS256"
os.environ.pop("AUTH_DISABLED", None)
os.environ.pop("JWT_AUDIENCE", None)
Expand Down