feat: guarddog-nexus — webhook-based PyPI scanner with web UI
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
131
tests/conftest.py
Normal file
131
tests/conftest.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Test fixtures for guarddog-nexus."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
os.environ["DATABASE_PATH"] = ":memory:"
|
||||
os.environ["NEXUS_URL"] = "http://nexus:8081"
|
||||
os.environ["NEXUS_USERNAME"] = "admin"
|
||||
os.environ["NEXUS_PASSWORD"] = "admin123"
|
||||
os.environ["LOG_SYSLOG_HOST"] = ""
|
||||
os.environ["TEMP_DIR"] = "/tmp/guarddog-nexus-test"
|
||||
|
||||
from guarddog_nexus.database import Base, get_session # noqa: E402
|
||||
from guarddog_nexus.main import app # noqa: E402
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_engine():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///file:guarddog_test?mode=memory&cache=shared&uri=true")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(db_engine):
|
||||
maker = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_engine):
|
||||
maker = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def override_get_session():
|
||||
async with maker() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = override_get_session
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_nexus_webhook():
|
||||
return {
|
||||
"timestamp": "2026-05-09T12:00:00.000+00:00",
|
||||
"nodeId": "test-node",
|
||||
"initiator": "admin",
|
||||
"action": "CREATED",
|
||||
"repositoryName": "pypi-proxy",
|
||||
"asset": {
|
||||
"name": "requests-2.31.0.tar.gz",
|
||||
"format": "pypi",
|
||||
"path": "packages/requests/2.31.0/requests-2.31.0.tar.gz",
|
||||
"downloadUrl": "http://nexus:8081/repository/pypi-proxy/packages/requests/2.31.0/requests-2.31.0.tar.gz",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarddog_output_clean():
|
||||
return {
|
||||
"results": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarddog_output_flagged():
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"rule": "shady-links",
|
||||
"severity": "WARNING",
|
||||
"message": "Package contains URL to suspicious domain",
|
||||
"location": "setup.py:15",
|
||||
},
|
||||
{
|
||||
"rule": "exec-base64",
|
||||
"severity": "ERROR",
|
||||
"message": "Base64-encoded code execution detected",
|
||||
"location": "core.py:42",
|
||||
},
|
||||
],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarddog_normalized_flagged():
|
||||
return {
|
||||
"findings": [
|
||||
{
|
||||
"rule": "shady-links",
|
||||
"severity": "WARNING",
|
||||
"message": "Suspicious URL",
|
||||
"location": "setup.py:15",
|
||||
},
|
||||
{
|
||||
"rule": "exec-base64",
|
||||
"severity": "ERROR",
|
||||
"message": "Base64 exec",
|
||||
"location": "core.py:42",
|
||||
},
|
||||
],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarddog_normalized_clean():
|
||||
return {
|
||||
"findings": [],
|
||||
"errors": [],
|
||||
}
|
||||
72
tests/test_api.py
Normal file
72
tests/test_api.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for REST API endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_scans_empty(client):
|
||||
resp = await client.get("/api/v1/scans")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert len(data["scans"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_stats_empty(client):
|
||||
resp = await client.get("/api/v1/scans/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_scans"] == 0
|
||||
assert data["flagged_scans"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_not_found(client):
|
||||
resp = await client.get("/api/v1/scans/99999")
|
||||
assert resp.status_code == 200
|
||||
assert "detail" in resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_packages_empty(client):
|
||||
resp = await client.get("/api/v1/packages")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_findings_empty(client):
|
||||
resp = await client.get("/api/v1/findings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_ui_dashboard(client):
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "GuardDog Nexus" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_ui_scans(client):
|
||||
resp = await client.get("/scans")
|
||||
assert resp.status_code == 200
|
||||
assert "Scans" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_ui_packages(client):
|
||||
resp = await client.get("/packages")
|
||||
assert resp.status_code == 200
|
||||
assert "Packages" in resp.text
|
||||
114
tests/test_harvester.py
Normal file
114
tests/test_harvester.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for harvester pipeline."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from guarddog_nexus.harvester import harvest
|
||||
from guarddog_nexus.models import Finding
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_new_package(db_session, guarddog_normalized_flagged):
|
||||
with (
|
||||
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
|
||||
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
|
||||
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
|
||||
):
|
||||
mock_dl.return_value = "/tmp/test-package.tar.gz"
|
||||
mock_sha.return_value = "abc123"
|
||||
mock_scan.return_value = guarddog_normalized_flagged
|
||||
|
||||
scan = await harvest(
|
||||
download_url="http://nexus:8081/repository/pypi-proxy/packages/requests/2.31.0/requests-2.31.0.tar.gz",
|
||||
repository="pypi-proxy",
|
||||
format_="pypi",
|
||||
asset_path="packages/requests/2.31.0/requests-2.31.0.tar.gz",
|
||||
session=db_session,
|
||||
)
|
||||
|
||||
assert scan is not None
|
||||
assert scan.package_name == "requests"
|
||||
assert scan.package_version == "2.31.0"
|
||||
assert scan.ecosystem == "pypi"
|
||||
assert scan.status == "completed"
|
||||
assert scan.flagged is True
|
||||
assert scan.total_findings == 2
|
||||
assert scan.sha256 == "abc123"
|
||||
|
||||
findings = (
|
||||
(await db_session.execute(select(Finding).where(Finding.scan_id == scan.id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(findings) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_skips_duplicate(db_session, guarddog_normalized_flagged):
|
||||
with (
|
||||
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
|
||||
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
|
||||
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
|
||||
):
|
||||
mock_dl.return_value = "/tmp/test.tar.gz"
|
||||
mock_sha.return_value = "abc"
|
||||
mock_scan.return_value = guarddog_normalized_flagged
|
||||
|
||||
first = await harvest(
|
||||
"http://nexus:8081/repo/pypi-proxy/packages/x/1.0/x-1.0.tar.gz",
|
||||
"pypi-proxy", "pypi", "packages/x/1.0/x-1.0.tar.gz", db_session,
|
||||
)
|
||||
second = await harvest(
|
||||
"http://nexus:8081/repo/pypi-proxy/packages/x/1.0/x-1.0.tar.gz",
|
||||
"pypi-proxy", "pypi", "packages/x/1.0/x-1.0.tar.gz", db_session,
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert second is None # skipped duplicate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_clean_package(db_session, guarddog_normalized_clean):
|
||||
with (
|
||||
patch("guarddog_nexus.harvester.download_asset") as mock_dl,
|
||||
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha,
|
||||
patch("guarddog_nexus.harvester.scan_package") as mock_scan,
|
||||
):
|
||||
mock_dl.return_value = "/tmp/test.tar.gz"
|
||||
mock_sha.return_value = "abc"
|
||||
mock_scan.return_value = guarddog_normalized_clean
|
||||
|
||||
scan = await harvest(
|
||||
"http://nexus:8081/repo/pypi-proxy/packages/django/4.2/django-4.2.tar.gz",
|
||||
"pypi-proxy", "pypi", "packages/django/4.2/django-4.2.tar.gz", db_session,
|
||||
)
|
||||
|
||||
assert scan is not None
|
||||
assert scan.flagged is False
|
||||
assert scan.total_findings == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_download_failure(db_session):
|
||||
with patch("guarddog_nexus.harvester.download_asset") as mock_dl:
|
||||
mock_dl.return_value = None
|
||||
|
||||
scan = await harvest(
|
||||
"http://nexus:8081/repo/pypi-proxy/packages/fail/1.0/fail-1.0.tar.gz",
|
||||
"pypi-proxy", "pypi", "packages/fail/1.0/fail-1.0.tar.gz", db_session,
|
||||
)
|
||||
|
||||
assert scan is not None
|
||||
assert scan.status == "failed"
|
||||
assert "Download failed" in (scan.error_message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_skips_non_package_asset(db_session):
|
||||
scan = await harvest(
|
||||
"http://nexus:8081/repo/pypi-proxy/simple/index.html",
|
||||
"pypi-proxy", "pypi", "simple/index.html", db_session,
|
||||
)
|
||||
assert scan is None
|
||||
28
tests/test_scanner.py
Normal file
28
tests/test_scanner.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Tests for GuardDog scanner integration."""
|
||||
|
||||
from guarddog_nexus.scanner import _normalize_output
|
||||
|
||||
|
||||
def test_normalize_clean_output(guarddog_output_clean):
|
||||
result = _normalize_output(guarddog_output_clean)
|
||||
assert len(result["findings"]) == 0
|
||||
assert len(result["errors"]) == 0
|
||||
|
||||
|
||||
def test_normalize_flagged_output(guarddog_output_flagged):
|
||||
result = _normalize_output(guarddog_output_flagged)
|
||||
assert len(result["findings"]) == 2
|
||||
assert result["findings"][0]["rule"] == "shady-links"
|
||||
assert result["findings"][0]["severity"] == "WARNING"
|
||||
assert result["findings"][1]["rule"] == "exec-base64"
|
||||
assert result["findings"][1]["severity"] == "ERROR"
|
||||
|
||||
|
||||
def test_normalize_issues_format():
|
||||
data = {
|
||||
"issues": [{"id": "test-rule", "severity": "ERROR", "description": "Bad"}],
|
||||
"errors": [],
|
||||
}
|
||||
result = _normalize_output(data)
|
||||
assert len(result["findings"]) == 1
|
||||
assert result["findings"][0]["rule"] == "test-rule"
|
||||
72
tests/test_webhooks.py
Normal file
72
tests/test_webhooks.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for Nexus webhook receiver."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_rejects_invalid_json(client):
|
||||
resp = await client.post(
|
||||
"/webhooks/nexus",
|
||||
content="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_ignores_deleted_action(client, sample_nexus_webhook):
|
||||
sample_nexus_webhook["action"] = "DELETED"
|
||||
resp = await client.post(
|
||||
"/webhooks/nexus",
|
||||
json=sample_nexus_webhook,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ignored"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_accepts_created(client, sample_nexus_webhook):
|
||||
with patch("guarddog_nexus.webhooks._scan_in_background") as _mock_scan:
|
||||
resp = await client.post(
|
||||
"/webhooks/nexus",
|
||||
json=sample_nexus_webhook,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "accepted"
|
||||
assert data["package"] == "requests-2.31.0.tar.gz"
|
||||
assert data["action"] == "CREATED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_accepts_updated(client, sample_nexus_webhook):
|
||||
sample_nexus_webhook["action"] = "UPDATED"
|
||||
with patch("guarddog_nexus.webhooks._scan_in_background") as _mock_scan:
|
||||
resp = await client.post(
|
||||
"/webhooks/nexus",
|
||||
json=sample_nexus_webhook,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "accepted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_skips_metadata_assets(client, sample_nexus_webhook):
|
||||
sample_nexus_webhook["asset"]["name"] = "index.html"
|
||||
resp = await client.post(
|
||||
"/webhooks/nexus",
|
||||
json=sample_nexus_webhook,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ignored"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_missing_asset(client):
|
||||
resp = await client.post(
|
||||
"/webhooks/nexus",
|
||||
json={"action": "CREATED", "repositoryName": "test"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user