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: 5 additions & 1 deletion backend/secuscan/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,8 +571,12 @@ async def _execute_standard_scanner(

# Apply Docker Sandboxing if enabled
if settings.docker_enabled:
from .sandbox_executor import resolve_sandbox_config
sandbox_cfg = resolve_sandbox_config(getattr(plugin, 'sandbox', None))

await self._ensure_docker_network()
docker_image = plugin.docker_image or "alpine:latest"
docker_network = "none" if not sandbox_cfg.allow_network else settings.docker_network
docker_cmd = [
"docker",
"run",
Expand All @@ -584,7 +588,7 @@ async def _execute_standard_scanner(
"--cpus",
str(settings.sandbox_cpu_quota),
"--cap-drop", "NET_RAW",
"--network", settings.docker_network,
"--network", docker_network,
docker_image,
]
command = docker_cmd + command
Expand Down
34 changes: 34 additions & 0 deletions backend/secuscan/migrations/009_add_saved_view_ownership.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Migration: 009_add_saved_view_ownership
-- Adds owner_id scoping and a shared flag to saved_views.
--
-- owner_id prevents cross-user IDOR: user A cannot modify/delete user B's
-- private view. The shared flag lets teams publish views as read-only to
-- other users without exposing mutation.
--
-- Backward-compatible: existing rows get owner_id = 'default' which
-- matches the DEFAULT_OWNER_ID constant in auth.py.
--
-- NOTE: The original table had UNIQUE(name). Ownership scoping requires
-- per-owner uniqueness instead (same name allowed across owners), so we
-- recreate the table without the global UNIQUE constraint.

CREATE TABLE saved_views_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
filter_json TEXT NOT NULL,
owner_id TEXT NOT NULL DEFAULT 'default',
shared INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO saved_views_new (id, name, filter_json, created_at, updated_at)
SELECT id, name, filter_json, created_at, updated_at
FROM saved_views;

DROP TABLE saved_views;

ALTER TABLE saved_views_new RENAME TO saved_views;

CREATE INDEX idx_saved_views_owner_id ON saved_views(owner_id);
CREATE INDEX idx_saved_views_shared ON saved_views(shared);
1 change: 1 addition & 0 deletions backend/secuscan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class PluginMetadata(BaseModel):
learning: Optional[Dict[str, Any]] = None
dependencies: Optional[Dict[str, List[str]]] = None
docker_image: Optional[str] = None
sandbox: Optional[SandboxConfig] = None

checksum: Optional[str] = None
signature: Optional[str] = None
Expand Down
15 changes: 15 additions & 0 deletions backend/secuscan/sandbox_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@ async def sandbox_execute(
violation_reason is None on success, or one of
"timeout", "memory_limit", "output_limit".
"""
# Enforce network isolation when allow_network is False.
# On Linux, use unshare(1) to create a new network namespace so the
# subprocess cannot reach any network interface. On non-Linux platforms
# we log a warning; full enforcement requires Docker --network none.
if not config.allow_network:
if IS_LINUX:
cmd = ["unshare", "--net", "--"] + cmd
logger.info("Network namespace isolation enabled (unshare --net)")
else:
logger.warning(
"allow_network=False requested but network namespace isolation "
"requires Linux unshare. Network access is NOT restricted on %s.",
platform.system(),
)

preexec_fn = _build_preexec_fn(config) if IS_LINUX else None

rss_before = 0
Expand Down
77 changes: 59 additions & 18 deletions backend/secuscan/saved_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import uuid
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, field_validator

from .database import get_db
Expand All @@ -14,6 +14,18 @@
_VALID_SORT_MODES = {"severity", "newest", "oldest", "target"}
_VALID_SEVERITIES = {"all", "critical", "high", "medium", "low", "info"}

# Default owner for backward-compatible single-user deployments.
_DEFAULT_OWNER = "default"
_OWNER_HEADER = "x-user-id"


async def _get_owner(request: Request) -> str:
"""FastAPI dependency that resolves the caller's owner identity."""
user_id = request.headers.get(_OWNER_HEADER)
if user_id and user_id.strip():
return f"user:{user_id.strip()}"
return _DEFAULT_OWNER


class FilterPreset(BaseModel):
"""Validated representation of the frontend filter state."""
Expand Down Expand Up @@ -44,6 +56,7 @@ class SavedViewCreate(BaseModel):
"""Request body for POST /saved-views."""
name: str = Field(..., min_length=1, max_length=60)
filter_json: str
shared: bool = False

@field_validator("name")
@classmethod
Expand All @@ -68,6 +81,7 @@ class SavedViewUpdate(BaseModel):
"""Request body for PUT /saved-views/{id}."""
name: Optional[str] = Field(None, min_length=1, max_length=60)
filter_json: Optional[str] = None
shared: Optional[bool] = None

@field_validator("name")
@classmethod
Expand Down Expand Up @@ -96,27 +110,30 @@ def validate_filter_json(cls, v: Optional[str]) -> Optional[str]:


@saved_views_router.get("")
async def list_saved_views() -> Dict[str, Any]:
"""Return all saved views ordered by creation date."""
async def list_saved_views(owner: str = Depends(_get_owner)) -> Dict[str, Any]:
"""Return saved views visible to the caller: own views + shared views from others."""
db = await get_db()
rows: List[Dict] = await db.fetchall(
"SELECT id, name, filter_json, created_at, updated_at "
"FROM saved_views ORDER BY created_at ASC"
"SELECT id, name, filter_json, owner_id, shared, created_at, updated_at "
"FROM saved_views WHERE owner_id = ? OR shared = 1 "
"ORDER BY created_at ASC",
(owner,),
)
return {"views": rows, "total": len(rows)}


@saved_views_router.post("", status_code=201)
async def create_saved_view(body: SavedViewCreate) -> Dict[str, Any]:
async def create_saved_view(body: SavedViewCreate, owner: str = Depends(_get_owner)) -> Dict[str, Any]:
"""
Create a new saved view.
Returns 409 if a view with the same name already exists.
Returns 409 if a view with the same name already exists for this owner.
"""
db = await get_db()


existing = await db.fetchone(
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?)", (body.name,)
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND owner_id = ?",
(body.name, owner),
)
if existing:
raise HTTPException(
Expand All @@ -128,34 +145,45 @@ async def create_saved_view(body: SavedViewCreate) -> Dict[str, Any]:
view_id = str(uuid.uuid4())
await db.execute(
"""
INSERT INTO saved_views (id, name, filter_json)
VALUES (?, ?, ?)
INSERT INTO saved_views (id, name, filter_json, owner_id, shared)
VALUES (?, ?, ?, ?, ?)
""",
(view_id, body.name, body.filter_json),
(view_id, body.name, body.filter_json, owner, int(body.shared)),
)
return {"id": view_id, "name": body.name, "created": True}


@saved_views_router.put("/{view_id}")
async def update_saved_view(view_id: str, body: SavedViewUpdate) -> Dict[str, Any]:
async def update_saved_view(view_id: str, body: SavedViewUpdate, owner: str = Depends(_get_owner)) -> Dict[str, Any]:
"""
Overwrite name and/or filter_json for an existing view.
Also accepts PATCH semantics — only supplied fields are updated.
Only the owner can modify a view; shared views are read-only to others.
"""
db = await get_db()

row = await db.fetchone("SELECT id FROM saved_views WHERE id = ?", (view_id,))
row = await db.fetchone(
"SELECT id, owner_id, shared FROM saved_views WHERE id = ?", (view_id,)
)
if not row:
raise HTTPException(status_code=404, detail="Saved view not found")

if row["owner_id"] != owner:
if row["shared"]:
raise HTTPException(
status_code=403,
detail="Cannot modify a shared view owned by another user.",
)
raise HTTPException(status_code=403, detail="Not authorized to modify this view.")

updates: List[str] = []
params: List[Any] = []

if body.name is not None:
# Check for name collision with a *different* record
# Check for name collision with a *different* record owned by the same user
collision = await db.fetchone(
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND id != ?",
(body.name, view_id),
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND id != ? AND owner_id = ?",
(body.name, view_id, owner),
)
if collision:
raise HTTPException(
Expand All @@ -169,6 +197,10 @@ async def update_saved_view(view_id: str, body: SavedViewUpdate) -> Dict[str, An
updates.append("filter_json = ?")
params.append(body.filter_json)

if body.shared is not None:
updates.append("shared = ?")
params.append(int(body.shared))

if not updates:
raise HTTPException(status_code=400, detail="No fields to update")

Expand All @@ -183,8 +215,17 @@ async def update_saved_view(view_id: str, body: SavedViewUpdate) -> Dict[str, An


@saved_views_router.delete("/{view_id}")
async def delete_saved_view(view_id: str) -> Dict[str, Any]:
"""Delete a saved view by id. Idempotent — returns 200 even if not found."""
async def delete_saved_view(view_id: str, owner: str = Depends(_get_owner)) -> Dict[str, Any]:
"""Delete a saved view by id. Only the owner can delete their views."""
db = await get_db()
row = await db.fetchone(
"SELECT id, owner_id FROM saved_views WHERE id = ?", (view_id,)
)
if not row:
return {"id": view_id, "deleted": True}

if row["owner_id"] != owner:
raise HTTPException(status_code=403, detail="Not authorized to delete this view.")

await db.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return {"id": view_id, "deleted": True}
4 changes: 2 additions & 2 deletions frontend/src/pages/Toolkit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export default function Scanner() {
</div>

<div>
<h3 className="text-3xl font-black text-silver-bright uppercase tracking-tighter italic leading-tight break-words min-w-0 group-hover:text-rag-red transition-colors">
<h3 className="text-3xl font-black text-silver-bright uppercase tracking-tighter italic leading-tight break-words min-w-0 group-hover:text-rag-red transition-colors line-clamp-2 text-ellipsis">
{tool.name}
</h3>
<div className="w-12 h-1 bg-silver-bright/10 mt-4 group-hover:w-full group-hover:bg-rag-red/30 transition-all duration-700" />
Expand All @@ -443,7 +443,7 @@ export default function Scanner() {
</p>

{tool.isPlugin && tool.availability && tool.availability.missing_binaries.length > 0 && (
<div className="text-[9px] uppercase tracking-widest text-rag-amber font-black leading-relaxed">
<div className="text-[9px] uppercase tracking-widest text-rag-amber font-black leading-relaxed line-clamp-2 text-ellipsis">
{tool.availability.guidance ||
`Unavailable: Requires external binaries (${tool.availability.missing_binaries.join(', ')})`}
</div>
Expand Down
92 changes: 92 additions & 0 deletions frontend/testing/unit/pages/Scans.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,96 @@ describe('Scans — task list', () => {
// Error is shown via toast, not the banner — no Close alert button expected
expect(screen.queryByRole('button', { name: /Close alert/i })).not.toBeInTheDocument()
})

it('handles bulkDeleteTasks failure: rows persist and error toast shown', async () => {
const { bulkDeleteTasks } = await import('../../../src/api')
vi.mocked(bulkDeleteTasks).mockRejectedValueOnce(new Error('Network Error or Internal Server Error'))

const tasks = [
makeTask({ task_id: 'task-1', tool: 'nmap', target: 'target1.com' }),
makeTask({ task_id: 'task-2', tool: 'nmap', target: 'target2.com' }),
]
mockFetch(tasks)
renderScans()

await waitFor(() => expect(screen.getByText('target1.com')).toBeInTheDocument())
expect(screen.getByText('target2.com')).toBeInTheDocument()

// Select all tasks
await userEvent.click(screen.getByRole('button', { name: /Select_All/i }))
await waitFor(() => {
expect(screen.getByText('2')).toBeInTheDocument()
})

// Click the bulk delete button
const deleteBtn = screen.getByRole('button', { name: /Prune_Selected/i })
await userEvent.click(deleteBtn)

// Confirm modal should appear
await waitFor(() => expect(screen.getByText('Bulk Delete Records')).toBeInTheDocument())

// Click Confirm
const confirmBtn = screen.getByRole('button', { name: /Confirm/i })
await userEvent.click(confirmBtn)

// Assert error toast is shown
await waitFor(() => {
const alertEl = screen.getByRole('alert')
expect(alertEl).toBeInTheDocument()
expect(alertEl.textContent).toMatch(/Failed to delete some tasks/)
})

// Assert rows are NOT removed — they persist despite the API failure
expect(screen.getByText('target1.com')).toBeInTheDocument()
expect(screen.getByText('target2.com')).toBeInTheDocument()

// Assert selection count is still shown (not cleared)
expect(screen.getByText('2')).toBeInTheDocument()
})

it('bulkDeleteTasks failure does not clear selection state', async () => {
const { bulkDeleteTasks } = await import('../../../src/api')
vi.mocked(bulkDeleteTasks).mockRejectedValueOnce(new Error('Server Error'))

const tasks = [
makeTask({ task_id: 'task-1', tool: 'nmap', target: 'target1.com' }),
makeTask({ task_id: 'task-2', tool: 'nmap', target: 'target2.com' }),
makeTask({ task_id: 'task-3', tool: 'nmap', target: 'target3.com' }),
]
mockFetch(tasks)
renderScans()

await waitFor(() => expect(screen.getByText('target1.com')).toBeInTheDocument())

// Select only first two tasks by clicking their selection divs (material icon "add")
const addIcons = screen.getAllByText('add')
await userEvent.click(addIcons[0])
await userEvent.click(addIcons[1])

await waitFor(() => {
expect(screen.getByText('2')).toBeInTheDocument()
})

// Click bulk delete
const deleteBtn = screen.getByRole('button', { name: /Prune_Selected/i })
await userEvent.click(deleteBtn)

await waitFor(() => expect(screen.getByText('Bulk Delete Records')).toBeInTheDocument())

const confirmBtn = screen.getByRole('button', { name: /Confirm/i })
await userEvent.click(confirmBtn)

// Wait for error
await waitFor(() => {
const alertEl = screen.getByRole('alert')
expect(alertEl).toBeInTheDocument()
})

// Verify the two tasks are still visible
expect(screen.getByText('target1.com')).toBeInTheDocument()
expect(screen.getByText('target2.com')).toBeInTheDocument()

// Verify selection count is still 2 (not cleared)
expect(screen.getByText('2')).toBeInTheDocument()
})
})
Loading
Loading