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
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,35 @@ jobs:
python -m pip install --upgrade pip
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run backend tests
run: pytest testing/backend -q
run: pytest testing/backend -q -m "not benchmark"

benchmark:
runs-on: ubuntu-latest
needs: [backend-lint]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install backend system dependencies
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run benchmarks
id: run_benchmarks
run: python3 scripts/run_benchmarks.py
continue-on-error: true
- name: Upload benchmark results artifact
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: benchmark_results.json
- name: Add warning annotation on failure
if: steps.run_benchmarks.outcome == 'failure'
run: |
echo "::warning::Performance benchmark thresholds exceeded or benchmarks failed to run. Check the job logs for details."

frontend-checks:
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ venv_tests/
*.swo
*~
.DS_Store

# Testing
.pytest_cache/
.coverage
htmlcov/
*.cover
.hypothesis/
benchmark_results.json

# Database
*.db
Expand Down Expand Up @@ -102,4 +102,4 @@ backend/__pycache__/
Thumbs.db

backend/data/reports/*
!backend/data/reports/.gitkeep
!backend/data/reports/.gitkeep
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,4 @@ Never commit these auto-generated paths:
If CI fails, run:
```bash
git rm --cached <file>
echo 'frontend/dist/' >> .gitignore
echo 'frontend/dist/' >> .gitignore
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,4 @@ This project is released under the [MIT License](LICENSE).

- `LICENSE` is the canonical legal text for this repository.
- Contributions merged into this repository are distributed under the same MIT License unless explicitly stated otherwise.
- Third-party tools, libraries, and external scanners referenced by SecuScan may have their own licenses and usage terms. Check upstream projects before redistributing bundled integrations.
- Third-party tools, libraries, and external scanners referenced by SecuScan may have their own licenses and usage terms. Check upstream projects before redistributing bundled integrations.
1 change: 0 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,3 @@ COPY plugins ./plugins
EXPOSE 8081

CMD ["uvicorn", "secuscan.api:app", "--host", "127.0.0.1", "--port", "8081"]

4 changes: 2 additions & 2 deletions backend/secuscan/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ async def get_json(self, key: str) -> Optional[Any]:
"""Retrieve and parse JSON from memory, respecting TTL."""
now = time.time()
expiry = self._expires.get(key)

if expiry and now > expiry:
# Clean up expired item
self._data.pop(key, None)
self._expires.pop(key, None)
return None

return self._data.get(key)

async def set_json(self, key: str, value: Any, ttl: Optional[int] = None):
Expand Down
39 changes: 31 additions & 8 deletions backend/secuscan/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
from backend.secuscan.plugins import init_plugins, get_plugin_manager
from backend.secuscan.reporting import reporting

async def run_scan(target: str, plugin_id: str, output_format: str, output_file: Optional[str] = None):

async def run_scan(
target: str, plugin_id: str, output_format: str, output_file: Optional[str] = None
):
"""Initialize components and execute a scan task."""

# Ensure directories exist
Expand All @@ -37,7 +40,11 @@ async def run_scan(target: str, plugin_id: str, output_format: str, output_file:
# If target is "." and no plugin specified, default to a sensible one for code
if target == "." and plugin_id == "nmap":
# Check if we should use secret_scanner or code_analyzer instead
plugin_id = "secret_scanner" if plugin_manager.get_plugin("secret_scanner") else "code_analyzer"
plugin_id = (
"secret_scanner"
if plugin_manager.get_plugin("secret_scanner")
else "code_analyzer"
)
print(f"[*] Detected directory target '.', defaulting to plugin: {plugin_id}")

plugin = plugin_manager.get_plugin(plugin_id)
Expand Down Expand Up @@ -88,7 +95,7 @@ async def monitor_output():
db = await get_db()
task_row = await db.fetchone(
"SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?",
(task_id,)
(task_id,),
)

if not task_row:
Expand All @@ -102,7 +109,9 @@ async def monitor_output():
print(f"\n[*] Scan completed successfully.")

# Generate report
structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {}
structured_data = (
json.loads(task_row["structured_json"]) if task_row["structured_json"] else {}
)
result_payload = {"structured": structured_data}

report_content: str = ""
Expand Down Expand Up @@ -132,15 +141,25 @@ async def monitor_output():

return 0


def main():
parser = argparse.ArgumentParser(description="SecuScan CLI - Local-First Pentesting Toolkit")
parser = argparse.ArgumentParser(
description="SecuScan CLI - Local-First Pentesting Toolkit"
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")

# Scan command
scan_parser = subparsers.add_parser("scan", help="Run a security scan")
scan_parser.add_argument("target", help="Target to scan (IP, Domain, or Path)")
scan_parser.add_argument("--plugin", default="nmap", help="Plugin ID to use (default: nmap)")
scan_parser.add_argument("--format", choices=["sarif", "json", "csv", "html", "console"], default="console", help="Output format")
scan_parser.add_argument(
"--plugin", default="nmap", help="Plugin ID to use (default: nmap)"
)
scan_parser.add_argument(
"--format",
choices=["sarif", "json", "csv", "html", "console"],
default="console",
help="Output format",
)
scan_parser.add_argument("--output", "-o", help="Output file path")

# List plugins command
Expand All @@ -149,7 +168,9 @@ def main():
args = parser.parse_args()

if args.command == "scan":
sys.exit(asyncio.run(run_scan(args.target, args.plugin, args.format, args.output)))
sys.exit(
asyncio.run(run_scan(args.target, args.plugin, args.format, args.output))
)
elif args.command == "plugins":
# Synchronous shortcut for listing
async def list_plugins():
Expand All @@ -159,9 +180,11 @@ async def list_plugins():
print("-" * 65)
for p_id, p in pm.plugins.items():
print(f"{p_id:<20} {p.name:<30} {p.category:<15}")

asyncio.run(list_plugins())
else:
parser.print_help()


if __name__ == "__main__":
main()
51 changes: 34 additions & 17 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,26 @@

class Settings(BaseSettings):
"""Application settings loaded from environment variables"""

# Server Configuration
bind_address: str = "127.0.0.1"
bind_port: int = 8000
debug: bool = True

# Primary data store
database_path: str = str(PROJECT_ROOT / "data" / "secuscan.db")

# Cache store (In-memory used when redis_url is None or Docker is disabled)
redis_url: Optional[str] = None
cache_ttl_seconds: int = 30

# Storage
data_dir: str = str(PROJECT_ROOT / "data")
raw_output_dir: str = str(PROJECT_ROOT / "data" / "raw")
reports_dir: str = str(PROJECT_ROOT / "data" / "reports")
plugins_dir: str = str(PROJECT_ROOT.parent / "plugins")
wordlists_dir: str = str(PROJECT_ROOT / "wordlists")

# Security
safe_mode_default: bool = True
require_consent: bool = True
Expand All @@ -49,45 +49,62 @@ class Settings(BaseSettings):
"http://localhost:4173",
"http://127.0.0.1:4173",
]
cors_allowed_methods: List[str] = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
cors_allowed_headers: List[str] = ["Content-Type", "Authorization", "Accept", "Origin"]
cors_allowed_methods: List[str] = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
]
cors_allowed_headers: List[str] = [
"Content-Type",
"Authorization",
"Accept",
"Origin",
]
cors_allow_credentials: bool = True
plugin_signature_key: Optional[str] = None
enforce_plugin_signatures: bool = False
vault_key: Optional[str] = None

# Rate Limiting
max_concurrent_tasks: int = 3
max_tasks_per_hour: int = 50
max_requests_per_minute: int = 100

# Sandbox
docker_enabled: bool = False
sandbox_timeout: int = 600 # seconds
sandbox_cpu_quota: float = 0.5
sandbox_memory_mb: int = 512

# Task-start payload limits (tunable via env vars)
task_start_max_body_bytes: int = 64_000 # 64 KB total JSON body
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
task_start_max_body_bytes: int = 64_000 # 64 KB total JSON body
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

# 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")
@field_validator(
"cors_allowed_origins",
"cors_allowed_methods",
"cors_allowed_headers",
mode="before",
)
@classmethod
def parse_csv_or_list(cls, value: Any) -> Any:
"""Allow comma-separated env values in addition to JSON arrays."""
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value

@property
def base_url(self) -> str:
"""Full base URL for the API"""
Expand All @@ -99,7 +116,7 @@ def resolved_vault_key(self) -> bytes:
seed = self.vault_key or self.plugin_signature_key or "secuscan-dev-key"
digest = hashlib.sha256(seed.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)

def ensure_directories(self) -> None:
"""Create necessary directories if they don't exist"""
for directory in [
Expand All @@ -109,11 +126,11 @@ def ensure_directories(self) -> None:
Path(self.log_file).parent,
]:
Path(directory).mkdir(parents=True, exist_ok=True)

# Create gitkeep files
(Path(self.raw_output_dir) / ".gitkeep").touch()
(Path(self.reports_dir) / ".gitkeep").touch()


# Global settings instance
settings = Settings()
settings = Settings()
14 changes: 9 additions & 5 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ def __init__(self, db_path: str):
def connection(self) -> aiosqlite.Connection:
"""Get the active database connection, raising an error if it's not connected."""
if self._connection is None:
raise RuntimeError("Database not connected. Did you forget to await connect()?")
raise RuntimeError(
"Database not connected. Did you forget to await connect()?"
)
return self._connection

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
Expand Down Expand Up @@ -182,7 +184,7 @@ async def _create_schema(self):
# Migration logic: ensure latest columns exist in 'tasks' table
tasks_columns = await self.fetchall("PRAGMA table_info(tasks)")
existing_cols = {col["name"] for col in tasks_columns}

needed_cols = {
"exit_code": "INTEGER",
"structured_json": "TEXT",
Expand All @@ -194,13 +196,15 @@ async def _create_schema(self):
"memory_peak_mb": "REAL",
"inputs_json": "TEXT NOT NULL DEFAULT '{}'",
"preset": "TEXT",
"safe_mode": "BOOLEAN NOT NULL DEFAULT 1"
"safe_mode": "BOOLEAN NOT NULL DEFAULT 1",
}

for col_name, col_type in needed_cols.items():
if col_name not in existing_cols:
try:
await self.execute(f"ALTER TABLE tasks ADD COLUMN {col_name} {col_type}")
await self.execute(
f"ALTER TABLE tasks ADD COLUMN {col_name} {col_type}"
)
print(f"Added missing column {col_name} to tasks table.")
except Exception as e:
print(f"Failed to add column {col_name}: {e}")
Expand Down
Loading
Loading