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
117 changes: 80 additions & 37 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
logging.StreamHandler(sys.stdout),
logging.FileHandler(settings.log_file)
if Path(settings.log_file).parent.exists()
else logging.NullHandler()
]
else logging.NullHandler(),
],
)

from .logging_utils import RequestIDFilter, JSONFormatter
Expand All @@ -40,78 +40,109 @@

logger = logging.getLogger(__name__)


@asynccontextmanager
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 API key authentication
api_key = init_api_key(settings.data_dir)
logger.info("✓ API key authentication ready (key file: %s/.api_key)", settings.data_dir)

logger.info(
"✓ API key authentication ready (key file: %s/.api_key)", settings.data_dir
)

# 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")

# If docker is enabled, verify and auto-create the restricted docker network
if settings.docker_enabled:
if shutil.which("docker"):
logger.info(f"Docker is enabled. Verifying network '{settings.docker_network}'...")
logger.info(
f"Docker is enabled. Verifying network '{settings.docker_network}'..."
)
try:
import subprocess

res = subprocess.run(
["docker", "network", "inspect", settings.docker_network],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if res.returncode != 0:
logger.info(f"Docker network '{settings.docker_network}' not found. Creating isolated bridge network (ICC disabled)...")
logger.info(
f"Docker network '{settings.docker_network}' not found. Creating isolated bridge network (ICC disabled)..."
)
creation_res = subprocess.run(
[
"docker", "network", "create",
"--driver", "bridge",
"--opt", "com.docker.network.bridge.enable_icc=false",
settings.docker_network
"docker",
"network",
"create",
"--driver",
"bridge",
"--opt",
"com.docker.network.bridge.enable_icc=false",
settings.docker_network,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if creation_res.returncode != 0:
logger.warning("Failed to create isolated bridge network with ICC disabled. Falling back to standard bridge...")
logger.warning(
"Failed to create isolated bridge network with ICC disabled. Falling back to standard bridge..."
)
subprocess.run(
["docker", "network", "create", "--driver", "bridge", settings.docker_network],
[
"docker",
"network",
"create",
"--driver",
"bridge",
settings.docker_network,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
logger.info(f"✓ Docker network '{settings.docker_network}' created (fallback)")
logger.info(
f"✓ Docker network '{settings.docker_network}' created (fallback)"
)
else:
logger.info(f"✓ Docker network '{settings.docker_network}' created with ICC disabled")
logger.info(
f"✓ Docker network '{settings.docker_network}' created with ICC disabled"
)
else:
logger.info(f"✓ Docker network '{settings.docker_network}' verified")
logger.info(
f"✓ Docker network '{settings.docker_network}' verified"
)
except Exception as e:
logger.warning(f"Failed to check/create Docker network '{settings.docker_network}': {e}")
logger.warning(
f"Failed to check/create Docker network '{settings.docker_network}': {e}"
)
else:
logger.warning("Docker sandboxing is enabled but 'docker' executable is not in PATH.")
logger.warning(
"Docker sandboxing is enabled but 'docker' executable is not in PATH."
)

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 All @@ -121,6 +152,7 @@ async def lifespan(app: FastAPI):
await scheduler.stop()
logger.info("✓ Shutdown complete")


# Create FastAPI application
app = FastAPI(
title="SecuScan API",
Expand All @@ -129,24 +161,31 @@ async def lifespan(app: FastAPI):
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan
lifespan=lifespan,
)


@app.get("/api/docs", include_in_schema=False)
async def redirect_api_docs():
from fastapi.responses import RedirectResponse

return RedirectResponse(url="/docs")


@app.get("/api/redoc", include_in_schema=False)
async def redirect_api_redoc():
from fastapi.responses import RedirectResponse

return RedirectResponse(url="/redoc")


@app.get("/api/openapi.json", include_in_schema=False)
async def redirect_api_openapi():
from fastapi.responses import RedirectResponse

return RedirectResponse(url="/openapi.json")


# CORS middleware
cors_allow_all = "*" in settings.cors_allowed_origins
if cors_allow_all and settings.cors_allow_credentials:
Expand Down Expand Up @@ -175,17 +214,19 @@ async def health_check():
"""Health check endpoint"""
import platform
import sys


logger.info("Health check endpoint accessed")
return {
"status": "operational",
"version": "0.1.0-alpha",
"system": {
"platform": platform.system(),
"python_version": sys.version.split()[0],
"docker_available": shutil.which("docker") is not None,
}
},
}


# Root endpoint
@app.get("/")
async def root():
Expand All @@ -195,31 +236,33 @@ async def root():
"version": "0.1.0-alpha",
"status": "under development",
"api_docs": f"{settings.base_url}/api/docs" if settings.debug else None,
"legal_notice": "For authorized testing only. Unauthorized scanning may be illegal."
"legal_notice": "For authorized testing only. Unauthorized scanning may be illegal.",
}


def main():
"""Main entry point"""
import uvicorn

logger.info("""
╔═══════════════════════════════════════════════════════
║ SecuScan v0.1.0-alpha
║ Local-First Pentesting Toolkit
⚠️ For authorized testing only
╚═══════════════════════════════════════════════════════
╔═══════════════════════════════════════════════════╗
║ ║
║ SecuScan v0.1.0-alpha ║
║ Local-First Pentesting Toolkit ║
║ ║
For authorized testing only
║ ║
╚═══════════════════════════════════════════════════╝
""")

uvicorn.run(
"backend.secuscan.main:app",
host=settings.bind_address,
port=settings.bind_port,
reload=settings.debug,
log_level=settings.log_level.lower()
log_level=settings.log_level.lower(),
)


if __name__ == "__main__":
main()
30 changes: 25 additions & 5 deletions backend/secuscan/workflows.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Workflow automation and scheduling."""

from __future__ import annotations
from .request_context import get_request_id, set_request_id
import asyncio
Expand All @@ -11,7 +12,10 @@
from .executor import executor
from .execution_context import normalize_execution_context
from .platform_resources import get_target_policy

logger = logging.getLogger(__name__)


class WorkflowScheduler:
def __init__(self):
self._task: asyncio.Task | None = None
Expand All @@ -23,6 +27,7 @@ async def start(self):
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("Workflow scheduler started")

async def stop(self):
self._running = False
if self._task:
Expand All @@ -33,13 +38,15 @@ async def stop(self):
pass
self._task = None
logger.info("Workflow scheduler stopped")

async def _run_loop(self):
while self._running:
try:
await self.tick()
except Exception as exc:
logger.error("Workflow scheduler tick failed: %s", exc)
await asyncio.sleep(5)

async def tick(self):
db = await get_db()
rows = await db.fetchall(
Expand All @@ -51,14 +58,21 @@ async def tick(self):
)
now = datetime.now(timezone.utc)
for row in rows:
if not self._should_run(now, row.get("last_run_at"), int(row["schedule_seconds"])):
if not self._should_run(
now, row.get("last_run_at"), int(row["schedule_seconds"])
):
continue
await self._run_workflow(row["id"], json.loads(row.get("steps_json") or "[]"))
await self._run_workflow(
row["id"], json.loads(row.get("steps_json") or "[]")
)
await db.execute(
"UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?",
(row["id"],),
)
def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds: int) -> bool:

def _should_run(
self, now: datetime, last_run_at: str | None, schedule_seconds: int
) -> bool:
if not last_run_at:
return True
last = datetime.fromisoformat(last_run_at.replace("Z", "+00:00"))
Expand All @@ -70,16 +84,22 @@ def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds:
last = last.replace(tzinfo=timezone.utc)
elapsed = (now - last).total_seconds()
return elapsed >= schedule_seconds

async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]]):
logger.info("Running workflow %s with %d step(s)", workflow_id, len(steps))
db = await get_db()
for step in steps:
plugin_id = step.get("plugin_id")
inputs = step.get("inputs") or {}
if not plugin_id:
continue
request_id = get_request_id()
execution_context = normalize_execution_context(step.get("execution_context") or {})
target_policy = await get_target_policy(db, "default", execution_context.get("target_policy_id"))
execution_context = normalize_execution_context(
step.get("execution_context") or {}
)
target_policy = await get_target_policy(
db, "default", execution_context.get("target_policy_id")
)
safe_mode = bool(
settings.safe_mode_default
and not (target_policy and target_policy.get("allow_public_targets"))
Expand Down
5 changes: 5 additions & 0 deletions frontend/testing/unit/pages/ToolConfigDynamic.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ describe('ToolConfig dynamic schema flow', () => {
}),
true,
'quick',
expect.objectContaining({
scan_profile: 'standard',
validation_mode: 'proof',
evidence_level: 'standard',
}),
)
})
})
Expand Down
3 changes: 3 additions & 0 deletions frontend/testing/unit/pages/ToolConfigTimeout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ vi.mock('../../../src/api', () => ({
getPluginSchema: vi.fn(),
startTask: vi.fn(),
getSettings: vi.fn(),
listTargetPolicies: vi.fn().mockResolvedValue({ items: [] }),
listCredentialProfiles: vi.fn().mockResolvedValue({ items: [] }),
listSessionProfiles: vi.fn().mockResolvedValue({ items: [] }),
}))

describe('ToolConfig timeout control', () => {
Expand Down
10 changes: 9 additions & 1 deletion frontend/testing/unit/pages/Workflows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,15 @@ describe('Workflows — create action', () => {
name: 'Nightly Scan',
schedule_seconds: 7200,
enabled: true,
steps: [{ plugin_id: '', inputs: {} }],
steps: [{
plugin_id: '',
inputs: {},
execution_context: {
scan_profile: 'standard',
validation_mode: 'proof',
evidence_level: 'standard',
},
}],
})
})
})
Expand Down
Loading
Loading