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
26 changes: 22 additions & 4 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ async def connect(self):
"""Establish database connection and ensure schema exists."""
# Ensure data directory exists
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)

conn = await aiosqlite.connect(self.db_path)
self._connection = conn
conn.row_factory = aiosqlite.Row
await self._create_schema()
await self._run_migrations()

async def disconnect(self):
"""Close the current database connection."""
Expand Down Expand Up @@ -265,7 +266,6 @@ async def _create_schema(self):
print("Added missing column 'proof' to findings table.")
except Exception as e:
print(f"Failed to add 'proof' to findings: {e}")

risk_cols = {
"exploitability": "REAL",
"confidence": "REAL",
Expand All @@ -281,6 +281,24 @@ async def _create_schema(self):
except Exception as e:
print(f"Failed to add column {col_name}: {e}")

async def _run_migrations(self):
migrations_dir = Path(__file__).parent / "migrations"

if not migrations_dir.exists():
raise RuntimeError(
f"Migrations directory not found at {migrations_dir} — "
"ensure the backend package is installed correctly."
)

for migration_file in sorted(migrations_dir.glob("*.sql")):
sql = migration_file.read_text(encoding="utf-8")
try:
await self.connection.executescript(sql)
except Exception as exc:
raise RuntimeError(
f"Migration {migration_file.name} failed — startup aborted: {exc}"
) from exc

await self._backfill_risk_scores()

async def _backfill_risk_scores(self):
Expand Down Expand Up @@ -326,13 +344,13 @@ async def execute(self, query: str, params: tuple = ()):

async def fetchone(self, query: str, params: tuple = ()) -> Optional[Dict]:
"""Fetch one row."""
async with self.connection.execute(query, params) as cursor:
async with await self.connection.execute(query, params) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None

async def fetchall(self, query: str, params: tuple = ()) -> List[Dict]:
"""Fetch all rows."""
async with self.connection.execute(query, params) as cursor:
async with await self.connection.execute(query, params) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]

Expand Down
20 changes: 11 additions & 9 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .database import init_db, db as global_db
from .plugins import init_plugins
from .routes import router
from .saved_views import saved_views_router
from .workflows import scheduler


Expand Down Expand Up @@ -46,29 +47,29 @@ async def lifespan(app: FastAPI):
"""Application lifespan manager"""
# Startup
logger.info("🚀 Starting SecuScan backend...")

# Ensure directories exist
settings.ensure_directories()
logger.info("✓ Directories initialized")

# Initialize database
await init_db(settings.database_path)
logger.info("✓ SQLite connected")

await init_cache()
logger.info("✓ In-memory cache initialized")

# Load plugins
await init_plugins(settings.plugins_dir)
logger.info("✓ Plugins loaded")

await scheduler.start()
logger.info("✓ Workflow scheduler started")

logger.info("✓ Ready to serve on %s:%d", settings.bind_address, settings.bind_port)

yield

# Shutdown
logger.info("🛑 Shutting down SecuScan backend...")
if global_db:
Expand Down Expand Up @@ -125,14 +126,15 @@ async def redirect_api_openapi():

# Include API routes
app.include_router(router)
app.include_router(saved_views_router)

# Health check endpoint
@app.get("/api/v1/health")
async def health_check():
"""Health check endpoint"""
import platform
import sys

return {
"status": "operational",
"version": "0.1.0-alpha",
Expand Down Expand Up @@ -160,7 +162,7 @@ async def root():
def main():
"""Main entry point"""
import uvicorn

logger.info("""
╔═══════════════════════════════════════════════════════╗
║ ║
Expand All @@ -171,7 +173,7 @@ def main():
║ ║
╚═══════════════════════════════════════════════════════╝
""")

uvicorn.run(
"backend.secuscan.main:app",
host=settings.bind_address,
Expand Down
21 changes: 21 additions & 0 deletions backend/secuscan/migrations/002_add_saved_views.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Migration: 002_add_saved_views
-- Adds the saved_views table for persisting named filter/sort/date presets
-- created by users on the Findings page.
--
-- Design notes:
-- - name is UNIQUE so the application-level 409 on duplicate names is
-- backed by a real database constraint.
-- - filter_json stores the serialised FilterPreset object; validated by
-- the API layer (Pydantic) before insert so the column is always valid.
-- - updated_at is maintained manually on PUT so callers can detect stale
-- local copies when merging localStorage with backend state.

CREATE TABLE IF NOT EXISTS saved_views (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
filter_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_saved_views_name ON saved_views(LOWER(name));
190 changes: 190 additions & 0 deletions backend/secuscan/saved_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
from __future__ import annotations

import json
import uuid
from typing import Any, Dict, List, Optional

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

from .database import get_db

saved_views_router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])

_VALID_SORT_MODES = {"severity", "newest", "oldest", "target"}
_VALID_SEVERITIES = {"all", "critical", "high", "medium", "low", "info"}


class FilterPreset(BaseModel):
"""Validated representation of the frontend filter state."""
severity: str = "all"
target: str = "all"
scanner: str = "all"
sortMode: str = "severity"
dateFrom: str = ""
dateTo: str = ""
searchQuery: str = ""

@field_validator("sortMode")
@classmethod
def validate_sort_mode(cls, v: str) -> str:
if v not in _VALID_SORT_MODES:
raise ValueError(f"sortMode must be one of {_VALID_SORT_MODES}")
return v

@field_validator("severity")
@classmethod
def validate_severity(cls, v: str) -> str:
if v not in _VALID_SEVERITIES:
raise ValueError(f"severity must be one of {_VALID_SEVERITIES}")
return v


class SavedViewCreate(BaseModel):
"""Request body for POST /saved-views."""
name: str = Field(..., min_length=1, max_length=60)
filter_json: str

@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("name cannot be blank")
return stripped

@field_validator("filter_json")
@classmethod
def validate_filter_json(cls, v: str) -> str:
try:
data = json.loads(v)
except json.JSONDecodeError as exc:
raise ValueError(f"filter_json is not valid JSON: {exc}") from exc
FilterPreset(**data)
return v


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

@field_validator("name")
@classmethod
def strip_name(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
stripped = v.strip()
if not stripped:
raise ValueError("name cannot be blank")
return stripped

@field_validator("filter_json")
@classmethod
def validate_filter_json(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
try:
data = json.loads(v)
except json.JSONDecodeError as exc:
raise ValueError(f"filter_json is not valid JSON: {exc}") from exc
FilterPreset(**data)
return v





@saved_views_router.get("")
async def list_saved_views() -> Dict[str, Any]:
"""Return all saved views ordered by creation date."""
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"
)
return {"views": rows, "total": len(rows)}


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


existing = await db.fetchone(
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?)", (body.name,)
)
if existing:
raise HTTPException(
status_code=409,
detail=f"A saved view named '{body.name}' already exists. "
"Use PUT to overwrite it.",
)

view_id = str(uuid.uuid4())
await db.execute(
"""
INSERT INTO saved_views (id, name, filter_json)
VALUES (?, ?, ?)
""",
(view_id, body.name, body.filter_json),
)
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]:
"""
Overwrite name and/or filter_json for an existing view.
Also accepts PATCH semantics — only supplied fields are updated.
"""
db = await get_db()

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

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

if body.name is not None:
# Check for name collision with a *different* record
collision = await db.fetchone(
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND id != ?",
(body.name, view_id),
)
if collision:
raise HTTPException(
status_code=409,
detail=f"Another saved view named '{body.name}' already exists.",
)
updates.append("name = ?")
params.append(body.name)

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

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

updates.append("updated_at = datetime('now')")
params.append(view_id)

await db.execute(
f"UPDATE saved_views SET {', '.join(updates)} WHERE id = ?",
tuple(params),
)
return {"id": view_id, "updated": True}


@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."""
db = await get_db()
await db.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return {"id": view_id, "deleted": True}
Loading
Loading