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
20 changes: 8 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,15 @@ SECUSCAN_VAULT_KEY=replace-with-output-of-secrets.token_hex-32
# SECUSCAN_PLUGIN_SIGNATURE_KEY=replace-with-your-signing-key
# SECUSCAN_ENFORCE_PLUGIN_SIGNATURES=false

# Plugin Capability Policy
# Comma-separated list of capabilities to deny across all plugins.
# Plugins that require any denied capability will fail before execution.
# Supported values: network, filesystem, docker, credentials, intrusive, exploit
# Example: deny all exploitation and credential-accessing plugins:
# SECUSCAN_DENIED_CAPABILITIES=exploit,credentials
# Parser Sandbox Limits
# Plugin parser.py files run in isolated subprocesses. Adjust these if you have
# plugins that produce very large output or need more time to parse.
# SECUSCAN_PARSER_SANDBOX_TIMEOUT_SECONDS=30
# SECUSCAN_PARSER_SANDBOX_MAX_OUTPUT_BYTES=8388608

# Frontend Overrides
# Leave these unset for the default local dev flow.
# VITE_API_PROXY_TARGET=http://127.0.0.1:8000
# VITE_API_BASE=http://127.0.0.1:8000/api/v1

# Artifact Retention (optional)
# max_age_days=0 / max_task_count=0 disables that policy.
# The background loop runs every interval_seconds (default: 3600 = 1 hour).
# SECUSCAN_RETENTION_MAX_AGE_DAYS=90
# SECUSCAN_RETENTION_MAX_TASK_COUNT=500
# SECUSCAN_RETENTION_KEEP_STATUSES=running,queued
# SECUSCAN_RETENTION_INTERVAL_SECONDS=3600
73 changes: 73 additions & 0 deletions backend/secuscan/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ async def monitor_output():

return 0

async def run_retention_cleanup(
max_age_days: int,
max_task_count: int,
keep_statuses: str,
dry_run: bool,
) -> int:
"""Perform a one-shot retention cleanup run and print a summary."""
settings.ensure_directories()
await init_db(settings.database_path)

from backend.secuscan.database import get_db
from backend.secuscan.retention import run_cleanup

db = await get_db()
keep_set = {s.strip() for s in keep_statuses.split(",") if s.strip()}

result = await run_cleanup(
db,
max_age_days=max_age_days,
max_task_count=max_task_count,
keep_statuses=keep_set,
dry_run=dry_run,
)

label = "[DRY-RUN] " if dry_run else ""
print(f"{label}Tasks {'would be ' if dry_run else ''}removed: {result.task_count}")
print(f"{label}Files {'would be ' if dry_run else ''}removed: {result.file_count}")
if result.tasks_removed:
for tid in result.tasks_removed:
print(f" {'would remove' if dry_run else 'removed'}: {tid}")
if result.errors:
print(f"Errors ({len(result.errors)}):")
for err in result.errors:
print(f" {err}")
return 1
return 0


def main():
parser = argparse.ArgumentParser(description="SecuScan CLI - Local-First Pentesting Toolkit")
subparsers = parser.add_subparsers(dest="command", help="Command to run")
Expand All @@ -147,10 +185,45 @@ def main():
# List plugins command
subparsers.add_parser("plugins", help="List available plugins")

# Cleanup command
cleanup_parser = subparsers.add_parser(
"cleanup",
help="Run artifact retention cleanup (supports --dry-run)",
)
cleanup_parser.add_argument(
"--max-age-days",
type=int,
default=settings.retention_max_age_days,
help="Remove tasks older than N days (0 = disabled)",
)
cleanup_parser.add_argument(
"--max-task-count",
type=int,
default=settings.retention_max_task_count,
help="Keep only the N most-recent tasks (0 = disabled)",
)
cleanup_parser.add_argument(
"--keep-statuses",
default=settings.retention_keep_statuses,
help="Comma-separated list of statuses to never purge (default: running,queued)",
)
cleanup_parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be deleted without making any changes",
)

args = parser.parse_args()

if args.command == "scan":
sys.exit(asyncio.run(run_scan(args.target, args.plugin, args.format, args.output)))
elif args.command == "cleanup":
sys.exit(asyncio.run(run_retention_cleanup(
max_age_days=args.max_age_days,
max_task_count=args.max_task_count,
keep_statuses=args.keep_statuses,
dry_run=args.dry_run,
)))
elif args.command == "plugins":
# Synchronous shortcut for listing
async def list_plugins():
Expand Down
17 changes: 13 additions & 4 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ class Settings(BaseSettings):
plugin_signature_key: Optional[str] = None
enforce_plugin_signatures: bool = False
vault_key: Optional[str] = None
denied_capabilities: List[str] = []

# Rate Limiting
max_concurrent_tasks: int = 3
Expand Down Expand Up @@ -91,9 +90,14 @@ class Settings(BaseSettings):
task_start_max_field_length: int = 1_000 # max chars per string input value
task_start_max_array_length: int = 50 # max items in any list/multiselect input

# Parser sandbox limits
parser_sandbox_timeout_seconds: int = 30
parser_sandbox_max_output_bytes: int = 8 * 1024 * 1024 # 8 MB
# Artifact Retention
# max_age_days=0 disables age-based cleanup; max_task_count=0 disables count-based cleanup.
retention_max_age_days: int = 0
retention_max_task_count: int = 0
# Comma-separated statuses that are never automatically purged.
retention_keep_statuses: str = "running,queued"
# How often (seconds) the background retention loop runs.
retention_interval_seconds: int = 3600

# Logging
log_level: str = "INFO"
Expand All @@ -111,6 +115,11 @@ def parse_csv_or_list(cls, value: Any) -> Any:
return [item.strip() for item in value.split(",") if item.strip()]
return value

@property
def retention_keep_statuses_set(self) -> set:
"""Return retention_keep_statuses as a Python set for easy membership tests."""
return {s.strip() for s in self.retention_keep_statuses.split(",") if s.strip()}

@property
def base_url(self) -> str:
"""Full base URL for the API"""
Expand Down
19 changes: 11 additions & 8 deletions backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@
from fastapi.staticfiles import StaticFiles

from .config import settings
from .auth import init_api_key
from .cache import init_cache, cache as global_cache
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
from .retention import retention_scheduler


logging.basicConfig(
Expand Down Expand Up @@ -52,10 +51,6 @@ async def lifespan(app: FastAPI):
# 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)

# Initialize database
await init_db(settings.database_path)
Expand All @@ -70,6 +65,15 @@ async def lifespan(app: FastAPI):

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

# Start artifact retention background loop (no-op when all limits are 0)
await retention_scheduler.start(
interval_seconds=settings.retention_interval_seconds,
max_age_days=settings.retention_max_age_days,
max_task_count=settings.retention_max_task_count,
keep_statuses=settings.retention_keep_statuses_set,
)
logger.info("✓ Retention scheduler started")

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

Expand All @@ -82,6 +86,7 @@ async def lifespan(app: FastAPI):
if global_cache:
await global_cache.disconnect()
await scheduler.stop()
await retention_scheduler.stop()
logger.info("✓ Shutdown complete")


Expand Down Expand Up @@ -131,8 +136,6 @@ 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")
Expand Down
Loading
Loading