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
11 changes: 6 additions & 5 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pathlib import Path
from typing import Any, List, Optional
from pydantic import field_validator
from pydantic import ConfigDict, field_validator
from pydantic_settings import BaseSettings
import base64
import hashlib
Expand All @@ -13,6 +13,11 @@


class Settings(BaseSettings):
model_config = ConfigDict(
env_prefix="SECUSCAN_",
case_sensitive=False,
)

"""Application settings loaded from environment variables"""

# Server Configuration
Expand Down Expand Up @@ -75,10 +80,6 @@ class Settings(BaseSettings):
# Logging
log_level: str = "INFO"
log_file: str = str(PROJECT_ROOT / "logs" / "secuscan.log")

class Config:
env_prefix = "SECUSCAN_"
case_sensitive = False

@field_validator("cors_allowed_origins", "cors_allowed_methods", "cors_allowed_headers", mode="before")
@classmethod
Expand Down
21 changes: 11 additions & 10 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 All @@ -38,29 +39,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 @@ -116,15 +117,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 @@ -152,7 +153,7 @@ async def root():
def main():
"""Main entry point"""
import uvicorn

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

uvicorn.run(
"backend.secuscan.main:app",
host=settings.bind_address,
Expand Down
215 changes: 215 additions & 0 deletions backend/secuscan/saved_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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



async def ensure_saved_views_table() -> None:
"""
Idempotently create the saved_views table.
Call this from the router startup or from database._create_schema.

If you prefer to add this SQL directly to database.py's _create_schema,
paste the CREATE TABLE block there and remove this function.
"""
db = await get_db()
await db.execute(
"""
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'))
)
"""
)



@saved_views_router.get("")
async def list_saved_views() -> Dict[str, Any]:
"""Return all saved views ordered by creation date."""
await ensure_saved_views_table()
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.
"""
await ensure_saved_views_table()
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.
"""
await ensure_saved_views_table()
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."""
await ensure_saved_views_table()
db = await get_db()
await db.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return {"id": view_id, "deleted": True}
Loading
Loading