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
4 changes: 3 additions & 1 deletion backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ async def _create_schema(self):
preset TEXT,
status TEXT NOT NULL DEFAULT 'queued',
scan_phase TEXT,
phase_timestamps_json TEXT NOT NULL DEFAULT '{}',
consent_granted BOOLEAN NOT NULL DEFAULT 0,
safe_mode BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
Expand Down Expand Up @@ -410,7 +411,8 @@ async def _create_schema(self):
"inputs_json": "TEXT NOT NULL DEFAULT '{}'",
"execution_context_json": "TEXT NOT NULL DEFAULT '{}'",
"preset": "TEXT",
"safe_mode": "BOOLEAN NOT NULL DEFAULT 1"
"safe_mode": "BOOLEAN NOT NULL DEFAULT 1",
"phase_timestamps_json": "TEXT NOT NULL DEFAULT '{}'"
}

for col_name, col_type in needed_cols.items():
Expand Down
27 changes: 22 additions & 5 deletions backend/secuscan/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,19 @@ async def _broadcast_phase(self, task_id: str, phase: str):
"""Broadcast a scan phase transition and persist it to the database."""
await self._broadcast(task_id, "phase", phase)
db = await get_db()
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"UPDATE tasks SET scan_phase = ? WHERE id = ?",
(phase, task_id)
"""
UPDATE tasks
SET scan_phase = ?,
phase_timestamps_json = json_set(
phase_timestamps_json,
'$.' || COALESCE(scan_phase, 'unknown') || '.completed_at', ?,
'$.' || ? || '.started_at', ?
)
WHERE id = ?
""",
(phase, now, phase, now, task_id)
)

async def create_task(
Expand Down Expand Up @@ -281,8 +291,8 @@ async def create_task(
"""
INSERT INTO tasks (
id, owner_id, plugin_id, tool_name, target, inputs_json, preset,
execution_context_json, status, scan_phase, consent_granted, safe_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
execution_context_json, status, scan_phase, phase_timestamps_json, consent_granted, safe_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
Expand All @@ -295,6 +305,7 @@ async def create_task(
serialize_execution_context(execution_context),
TaskStatus.QUEUED.value,
ScanPhase.QUEUED.value,
json.dumps({ScanPhase.QUEUED.value: {"started_at": datetime.now(timezone.utc).isoformat()}}),
consent_granted,
bool(safe_mode)
)
Expand Down Expand Up @@ -968,7 +979,7 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]:
db = await get_db()
task_row = await db.fetchone(
"""
SELECT id, plugin_id, tool_name, target, status, scan_phase, created_at, started_at, completed_at,
SELECT id, plugin_id, tool_name, target, status, scan_phase, phase_timestamps_json, created_at, started_at, completed_at,
duration_seconds, exit_code, error_message, preset, inputs_json, execution_context_json
FROM tasks WHERE id = ?
""",
Expand All @@ -989,13 +1000,19 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]:
pending_count = len(ids)
queue_position = (ids.index(task_id) + 1) if task_id in ids else None

try:
phase_timestamps = json.loads(_row_value(task_row, "phase_timestamps_json", "{}"))
except json.JSONDecodeError:
phase_timestamps = {}

return {
"task_id": task_row["id"],
"plugin_id": task_row["plugin_id"],
"tool": task_row["tool_name"],
"target": task_row["target"],
"status": task_row["status"],
"scan_phase": task_row.get("scan_phase"),
"phase_timestamps": phase_timestamps,
"created_at": task_row["created_at"],
"started_at": task_row["started_at"],
"completed_at": task_row["completed_at"],
Expand Down
15 changes: 11 additions & 4 deletions testing/backend/unit/test_scan_phases.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def make_task_row(task_id: str, status: str, scan_phase: str = None):
"target": "127.0.0.1",
"status": status,
"scan_phase": scan_phase,
"phase_timestamps_json": "{}",
"created_at": "2026-01-01T00:00:00",
"started_at": None,
"completed_at": None,
Expand Down Expand Up @@ -136,10 +137,14 @@ async def test_broadcast_phase_persists_to_db():
await executor._broadcast_phase("task-1", ScanPhase.PARSING.value)

# Verify the DB was updated
mock_db.execute.assert_called_once_with(
"UPDATE tasks SET scan_phase = ? WHERE id = ?",
(ScanPhase.PARSING.value, "task-1")
)
assert mock_db.execute.call_count == 1
args = mock_db.execute.call_args[0]
assert "UPDATE tasks" in args[0]
assert "SET scan_phase = ?" in args[0]
assert "phase_timestamps_json = json_set(" in args[0]
assert args[1][0] == ScanPhase.PARSING.value
assert args[1][2] == ScanPhase.PARSING.value
assert args[1][4] == "task-1"


@pytest.mark.asyncio
Expand Down Expand Up @@ -205,6 +210,7 @@ async def test_scan_phase_included_in_status_response():
"target": "127.0.0.1",
"status": TaskStatus.RUNNING.value,
"scan_phase": ScanPhase.RUNNING_COMMAND.value,
"phase_timestamps_json": '{"queued": {"started_at": "2026-01-01T00:00:00"}}',
"created_at": "2026-01-01T00:00:00",
"started_at": None,
"completed_at": None,
Expand All @@ -219,3 +225,4 @@ async def test_scan_phase_included_in_status_response():
result = await _call_with_mock_db(executor, "task-sse-1", mock_db)

assert result["scan_phase"] == ScanPhase.RUNNING_COMMAND.value
assert "queued" in result["phase_timestamps"]
Loading