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
28 changes: 26 additions & 2 deletions backend/secuscan/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
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, dry_run: bool = False):
"""Initialize components and execute a scan task."""

# Ensure directories exist
Expand Down Expand Up @@ -50,6 +50,29 @@ async def run_scan(target: str, plugin_id: str, output_format: str, output_file:
# Create task
safe_mode = bool(settings.safe_mode_default)
inputs = {"target": target, "safe_mode": safe_mode}

if dry_run:
command = plugin_manager.build_command(plugin_id, inputs)
if not command:
print(f"Error: Failed to build command for plugin {plugin_id}")
return 1

if settings.docker_enabled:
docker_image = getattr(plugin, "docker_image", "alpine:latest") or "alpine:latest"
docker_cmd = [
"docker", "run", "--rm",
"--name", "secuscan_task_DRY_RUN",
"--memory", f"{settings.sandbox_memory_mb}m",
"--cpus", str(settings.sandbox_cpu_quota),
"--cap-drop", "NET_RAW",
"--network", settings.docker_network,
docker_image,
]
command = docker_cmd + command

print("[*] Dry run command:")
print(" ".join(command))
return 0
try:
task_id = await executor.create_task(plugin_id, inputs, safe_mode=safe_mode, consent_granted=True)
except Exception as e:
Expand Down Expand Up @@ -143,14 +166,15 @@ def main():
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")
scan_parser.add_argument("--dry-run", action="store_true", help="Print the resolved command without executing")

# List plugins command
subparsers.add_parser("plugins", help="List available plugins")

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, args.dry_run)))
elif args.command == "plugins":
# Synchronous shortcut for listing
async def list_plugins():
Expand Down
31 changes: 31 additions & 0 deletions testing/backend/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,34 @@ async def test_run_scan_failed_task_returns_1():

result = await run_scan("127.0.0.1", "nmap", "console")
assert result == 1


@pytest.mark.anyio
async def test_run_scan_dry_run_prints_command_and_avoids_execution(capsys):
"""Test --dry-run prints resolved command, returns 0, and avoids task creation/execution."""
mock_plugin = MagicMock()
mock_plugin.name = "Nmap"
mock_plugin.docker_image = "secuscan/nmap:latest"

mock_pm = MagicMock()
mock_pm.get_plugin.return_value = mock_plugin
mock_pm.build_command.return_value = ["nmap", "-sV", "127.0.0.1"]

mock_executor = AsyncMock()

with patch("backend.secuscan.cli.init_db", new_callable=AsyncMock), \
patch("backend.secuscan.cli.init_cache", new_callable=AsyncMock), \
patch("backend.secuscan.cli.init_plugins", new_callable=AsyncMock), \
patch("backend.secuscan.cli.get_plugin_manager", return_value=mock_pm), \
patch("backend.secuscan.cli.executor", mock_executor):

result = await run_scan("127.0.0.1", "nmap", "console", dry_run=True)
assert result == 0

mock_pm.build_command.assert_called_once()
mock_executor.create_task.assert_not_called()
mock_executor.execute_task.assert_not_called()

captured = capsys.readouterr()
assert "[*] Dry run command:" in captured.out
assert "nmap -sV 127.0.0.1" in captured.out
111 changes: 31 additions & 80 deletions testing/backend/unit/test_saved_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest_asyncio
from httpx import AsyncClient, ASGITransport

from fastapi import FastAPI
from fastapi import FastAPI, Depends
from backend.secuscan.saved_views import saved_views_router
from backend.secuscan.database import Database, get_db
from backend.secuscan.auth import require_api_key
Expand Down Expand Up @@ -38,7 +38,7 @@ async def app_client():

# Minimal app with auth override
_app = FastAPI()
_app.include_router(saved_views_router)
_app.include_router(saved_views_router, dependencies=[Depends(require_api_key)])

# Override auth dependency to bypass authentication in tests
_app.dependency_overrides[require_api_key] = _mock_require_api_key
Expand All @@ -52,6 +52,7 @@ async def app_client():
base_url="http://test",
headers={"X-Api-Key": api_key},
) as client:
client.app = _app
client.api_key = api_key
client.test_transport = transport
yield client
Expand Down Expand Up @@ -355,21 +356,37 @@ async def test_filter_json_with_null_values_rejected(app_client: AsyncClient):
# ─── Auth & owner isolation (issue #1743) ────────────────────────────────────

@pytest.mark.asyncio
async def test_unauthenticated_request_rejected(app_client: AsyncClient):
async def test_unauthenticated_request_rejected(app_client: AsyncClient, monkeypatch):
"""Requests without a valid API key/session are rejected, not served."""
res = await app_client.get(
"/api/v1/saved-views", headers={"X-Api-Key": ""}
)
assert res.status_code == 401
from backend.secuscan import auth as auth_module
monkeypatch.setattr(auth_module, "_api_key", "real-secret-key-12345")
app_client.app.dependency_overrides.pop(require_api_key, None)
try:
res = await app_client.get(
"/api/v1/saved-views", headers={"X-Api-Key": ""}
)
assert res.status_code == 401
finally:
app_client.app.dependency_overrides[require_api_key] = lambda: {
"user_id": "test_user_123"
}


@pytest.mark.asyncio
async def test_wrong_api_key_rejected(app_client: AsyncClient):
async def test_wrong_api_key_rejected(app_client: AsyncClient, monkeypatch):
"""A malformed/incorrect API key is rejected."""
res = await app_client.get(
"/api/v1/saved-views", headers={"X-Api-Key": "not-the-real-key"}
)
assert res.status_code == 401
from backend.secuscan import auth as auth_module
monkeypatch.setattr(auth_module, "_api_key", "real-secret-key-12345")
app_client.app.dependency_overrides.pop(require_api_key, None)
try:
res = await app_client.get(
"/api/v1/saved-views", headers={"X-Api-Key": "not-the-real-key"}
)
assert res.status_code == 401
finally:
app_client.app.dependency_overrides[require_api_key] = lambda: {
"user_id": "test_user_123"
}


@pytest.mark.asyncio
Expand Down Expand Up @@ -403,6 +420,7 @@ async def test_cannot_read_other_owners_view_by_guessing_id(
"""
There's no GET-by-id endpoint, but PUT/DELETE both accept a bare id — this
verifies neither leaks or mutates another owner's row (BOLA regression).
The server returns 403 when a view exists but belongs to a different owner.
"""
create_res = await app_client.post("/api/v1/saved-views", json=make_body("Private View"))
view_id = create_res.json()["id"]
Expand All @@ -424,7 +442,7 @@ async def test_cannot_read_other_owners_view_by_guessing_id(
async def test_cannot_delete_other_owners_view(
app_client: AsyncClient, other_owner_client: AsyncClient
):
"""Deleting another owner's view id returns 403 and leaves it intact."""
"""Deleting another owner's view id returns 403 and leaves the view intact."""
create_res = await app_client.post("/api/v1/saved-views", json=make_body("Keep Safe"))
view_id = create_res.json()["id"]

Expand Down Expand Up @@ -563,70 +581,3 @@ def test_malformed_json_raises(self):
SavedViewCreate(name="v", filter_json="not json")
# pydantic raises an error for invalid JSON in field_validator
assert "validation error" in str(exc_info.value).lower()

@pytest.mark.asyncio
async def test_migrations_are_idempotent(tmp_path):
db_file = tmp_path / "idempotent.db"

db = Database(str(db_file))
await db.connect()
await db.disconnect()

db = Database(str(db_file))
await db.connect()

rows = await db.fetchall(
"SELECT COUNT(*) AS count FROM schema_migrations"
)

migration_count = len(
list((Path(_db_module.__file__).parent / "migrations").glob("*.sql"))
)

assert rows[0]["count"] == migration_count

await db.disconnect()


@pytest.mark.asyncio
async def test_schema_version_is_recorded(tmp_path):
db_file = tmp_path / "schema.db"

db = Database(str(db_file))
await db.connect()

rows = await db.fetchall(
"SELECT version FROM schema_migrations ORDER BY version"
)

assert rows

assert any(
row["version"] == "001_add_performance_indexes.sql"
for row in rows
)

await db.disconnect()


@pytest.mark.asyncio
async def test_database_newer_than_application_fails(tmp_path):
db_file = tmp_path / "future.db"

db = Database(str(db_file))
await db.connect()

await db.execute(
"""
INSERT INTO schema_migrations(version)
VALUES (?)
""",
("999_future.sql",),
)

await db.disconnect()

db = Database(str(db_file))

with pytest.raises(RuntimeError, match="Database schema is newer"):
await db.connect()
Loading