diff --git a/guarddog_nexus/api/__init__.py b/guarddog_nexus/core/__init__.py similarity index 100% rename from guarddog_nexus/api/__init__.py rename to guarddog_nexus/core/__init__.py diff --git a/guarddog_nexus/harvester.py b/guarddog_nexus/core/harvester.py similarity index 94% rename from guarddog_nexus/harvester.py rename to guarddog_nexus/core/harvester.py index 31dfa23..08f8aaf 100644 --- a/guarddog_nexus/harvester.py +++ b/guarddog_nexus/core/harvester.py @@ -9,17 +9,17 @@ import tempfile from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from guarddog_nexus.config import config -from guarddog_nexus.constants import ( +from ..config import config +from ..constants import ( DEFAULT_ECOSYSTEM, ERROR_MESSAGE_MAX_LENGTH, PACKAGE_EXTENSIONS, SCAN_ERROR_DOWNLOAD_FAILED, ) -from guarddog_nexus.logging_setup import log -from guarddog_nexus.models import Finding, Scan, ScanStatus -from guarddog_nexus.nexus_client import compute_sha256, download_asset, extract_package_info -from guarddog_nexus.scanner import scan_package +from ..db.models import Finding, Scan, ScanStatus +from ..logging_setup import log +from .nexus import compute_sha256, download_asset, extract_package_info +from .scanner import scan_package # Per-URL locks to avoid parallel scans of the same asset _url_locks: dict[str, asyncio.Lock] = {} @@ -193,7 +193,7 @@ async def harvest( async def _run_llm_analysis(findings: list[Finding], session: AsyncSession) -> list[dict]: """Run LLM analysis on findings and persist reports to the database.""" - from guarddog_nexus.llm import analyze_finding + from .llm import analyze_finding reports = [] for finding in findings: diff --git a/guarddog_nexus/llm.py b/guarddog_nexus/core/llm.py similarity index 95% rename from guarddog_nexus/llm.py rename to guarddog_nexus/core/llm.py index 41f93e2..87dbd08 100644 --- a/guarddog_nexus/llm.py +++ b/guarddog_nexus/core/llm.py @@ -7,9 +7,9 @@ import json import httpx -from guarddog_nexus.config import config -from guarddog_nexus.constants import LLM_ANALYSIS_SYSTEM_PROMPT -from guarddog_nexus.logging_setup import log +from ..config import config +from ..constants import LLM_ANALYSIS_SYSTEM_PROMPT +from ..logging_setup import log def _build_user_message(finding: dict) -> str: diff --git a/guarddog_nexus/nexus_client.py b/guarddog_nexus/core/nexus.py similarity index 96% rename from guarddog_nexus/nexus_client.py rename to guarddog_nexus/core/nexus.py index db466f3..bd207de 100644 --- a/guarddog_nexus/nexus_client.py +++ b/guarddog_nexus/core/nexus.py @@ -5,13 +5,13 @@ import os import httpx -from guarddog_nexus.config import config -from guarddog_nexus.constants import ( +from ..config import config +from ..constants import ( NPM_PATH_PREFIX, PYPI_PATH_PREFIX, SHA256_CHUNK_SIZE, ) -from guarddog_nexus.logging_setup import log +from ..logging_setup import log def extract_pypi_info(asset_path: str) -> tuple[str, str] | None: diff --git a/guarddog_nexus/scanner.py b/guarddog_nexus/core/scanner.py similarity index 96% rename from guarddog_nexus/scanner.py rename to guarddog_nexus/core/scanner.py index 507fbef..18ac1bf 100644 --- a/guarddog_nexus/scanner.py +++ b/guarddog_nexus/core/scanner.py @@ -3,8 +3,8 @@ import asyncio import json -from guarddog_nexus.config import config -from guarddog_nexus.constants import ( +from ..config import config +from ..constants import ( DEFAULT_ECOSYSTEM, DEFAULT_FINDING_SEVERITY, GUARDDOG_OUTPUT_FORMAT, @@ -14,7 +14,7 @@ from guarddog_nexus.constants import ( SCAN_ERROR_JSON_PARSE, SCAN_ERROR_TIMEOUT, ) -from guarddog_nexus.logging_setup import log +from ..logging_setup import log async def scan_package(filepath: str, ecosystem: str = DEFAULT_ECOSYSTEM) -> dict: diff --git a/guarddog_nexus/web/__init__.py b/guarddog_nexus/db/__init__.py similarity index 100% rename from guarddog_nexus/web/__init__.py rename to guarddog_nexus/db/__init__.py diff --git a/guarddog_nexus/database.py b/guarddog_nexus/db/engine.py similarity index 96% rename from guarddog_nexus/database.py rename to guarddog_nexus/db/engine.py index ebab461..6a16bbe 100644 --- a/guarddog_nexus/database.py +++ b/guarddog_nexus/db/engine.py @@ -20,7 +20,7 @@ class Base(DeclarativeBase): async def _migrate(): """Add any missing columns from model definitions to existing SQLite tables.""" - import guarddog_nexus.models # noqa: F401 + import guarddog_nexus.db.models # noqa: F401 async with _engine.connect() as conn: for table in Base.metadata.sorted_tables: @@ -63,7 +63,7 @@ async def _migrate(): async def init_db(): - import guarddog_nexus.models # noqa: F401 + import guarddog_nexus.db.models # noqa: F401 async with _engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/guarddog_nexus/models.py b/guarddog_nexus/db/models.py similarity index 98% rename from guarddog_nexus/models.py rename to guarddog_nexus/db/models.py index 4ff5378..3b19376 100644 --- a/guarddog_nexus/models.py +++ b/guarddog_nexus/db/models.py @@ -6,7 +6,7 @@ from enum import Enum from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship -from guarddog_nexus.database import Base +from guarddog_nexus.db.engine import Base class ScanStatus(str, Enum): diff --git a/guarddog_nexus/queries.py b/guarddog_nexus/db/queries.py similarity index 99% rename from guarddog_nexus/queries.py rename to guarddog_nexus/db/queries.py index 6c84026..aca80f3 100644 --- a/guarddog_nexus/queries.py +++ b/guarddog_nexus/db/queries.py @@ -20,7 +20,7 @@ from guarddog_nexus.constants import ( SCAN_SORT_FIELDS, TOP_RULES_LIMIT, ) -from guarddog_nexus.models import Finding, Scan +from guarddog_nexus.db.models import Finding, Scan # --------------------------------------------------------------------------- # Scan list query builder diff --git a/guarddog_nexus/logging_setup.py b/guarddog_nexus/logging_setup.py index 08522b1..7a74a6b 100644 --- a/guarddog_nexus/logging_setup.py +++ b/guarddog_nexus/logging_setup.py @@ -5,8 +5,8 @@ import logging import sys from logging.handlers import SysLogHandler -from guarddog_nexus.config import config -from guarddog_nexus.constants import APP_PACKAGE +from .config import config +from .constants import APP_PACKAGE class JsonFormatter(logging.Formatter): diff --git a/guarddog_nexus/main.py b/guarddog_nexus/main.py index 04bfacd..43b2e55 100644 --- a/guarddog_nexus/main.py +++ b/guarddog_nexus/main.py @@ -7,13 +7,13 @@ import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from guarddog_nexus.api import findings, packages, scans from guarddog_nexus.config import config from guarddog_nexus.constants import APP_DESCRIPTION, APP_NAME, APP_PACKAGE, STATIC_MOUNT_PATH -from guarddog_nexus.database import init_db +from guarddog_nexus.db.engine import init_db from guarddog_nexus.logging_setup import log -from guarddog_nexus.web.routes import router as web_router -from guarddog_nexus.webhooks import router as webhook_router +from guarddog_nexus.routes import api_findings, api_packages, api_scans +from guarddog_nexus.routes.web import router as web_router +from guarddog_nexus.routes.webhooks import router as webhook_router STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static") @@ -34,9 +34,9 @@ app = FastAPI( ) app.include_router(webhook_router) -app.include_router(scans.router) -app.include_router(packages.router) -app.include_router(findings.router) +app.include_router(api_scans.router) +app.include_router(api_packages.router) +app.include_router(api_findings.router) app.include_router(web_router) if os.path.isdir(STATIC_DIR): diff --git a/guarddog_nexus/routes/__init__.py b/guarddog_nexus/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guarddog_nexus/api/findings.py b/guarddog_nexus/routes/api_findings.py similarity index 91% rename from guarddog_nexus/api/findings.py rename to guarddog_nexus/routes/api_findings.py index 84b7531..823b51c 100644 --- a/guarddog_nexus/api/findings.py +++ b/guarddog_nexus/routes/api_findings.py @@ -4,16 +4,16 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from guarddog_nexus.config import config -from guarddog_nexus.constants import ( +from ..config import config +from ..constants import ( DEFAULT_OFFSET, DEFAULT_PAGE_SIZE, JSON_PATH_RULE, JSON_PATH_SEVERITY, MAX_PAGE_SIZE, ) -from guarddog_nexus.database import get_session -from guarddog_nexus.models import Finding +from ..db.engine import get_session +from ..db.models import Finding router = APIRouter(prefix="/api/v1/findings", tags=["findings"]) @@ -70,7 +70,7 @@ async def analyze_finding_endpoint( if not finding: return {"detail": "Not found"} - from guarddog_nexus.llm import analyze_finding + from ..core.llm import analyze_finding report = await analyze_finding(finding.data) if report is None: diff --git a/guarddog_nexus/api/packages.py b/guarddog_nexus/routes/api_packages.py similarity index 95% rename from guarddog_nexus/api/packages.py rename to guarddog_nexus/routes/api_packages.py index 44494fc..ec9d4d0 100644 --- a/guarddog_nexus/api/packages.py +++ b/guarddog_nexus/routes/api_packages.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from guarddog_nexus.constants import ( +from ..constants import ( CSV_MEDIA_TYPE, DEFAULT_OFFSET, DEFAULT_PAGE_SIZE, @@ -16,9 +16,9 @@ from guarddog_nexus.constants import ( DEFAULT_SORT_DIR, MAX_PAGE_SIZE, ) -from guarddog_nexus.database import get_session -from guarddog_nexus.models import Finding, Scan -from guarddog_nexus.queries import build_package_list_query +from ..db.engine import get_session +from ..db.models import Finding, Scan +from ..db.queries import build_package_list_query router = APIRouter(prefix="/api/v1/packages", tags=["packages"]) diff --git a/guarddog_nexus/api/scans.py b/guarddog_nexus/routes/api_scans.py similarity index 96% rename from guarddog_nexus/api/scans.py rename to guarddog_nexus/routes/api_scans.py index b2f4b45..aed5379 100644 --- a/guarddog_nexus/api/scans.py +++ b/guarddog_nexus/routes/api_scans.py @@ -8,7 +8,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from guarddog_nexus.constants import ( +from ..constants import ( CSV_MEDIA_TYPE, DEFAULT_OFFSET, DEFAULT_PAGE_SIZE, @@ -16,9 +16,9 @@ from guarddog_nexus.constants import ( DEFAULT_SORT_DIR, MAX_PAGE_SIZE, ) -from guarddog_nexus.database import get_session -from guarddog_nexus.models import Scan -from guarddog_nexus.queries import build_scan_list_query, get_dashboard_stats +from ..db.engine import get_session +from ..db.models import Scan +from ..db.queries import build_scan_list_query, get_dashboard_stats router = APIRouter(prefix="/api/v1/scans", tags=["scans"]) diff --git a/guarddog_nexus/web/routes.py b/guarddog_nexus/routes/web.py similarity index 95% rename from guarddog_nexus/web/routes.py rename to guarddog_nexus/routes/web.py index d1960a0..a91f876 100644 --- a/guarddog_nexus/web/routes.py +++ b/guarddog_nexus/routes/web.py @@ -8,16 +8,16 @@ from jinja2 import Environment, PackageLoader, select_autoescape from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from guarddog_nexus.constants import ( +from ..constants import ( APP_PACKAGE, DEFAULT_SORT_BY_PACKAGES, DEFAULT_SORT_BY_SCANS, DEFAULT_SORT_DIR, WEB_PER_PAGE, ) -from guarddog_nexus.database import get_session -from guarddog_nexus.models import Finding, Scan -from guarddog_nexus.queries import ( +from ..db.engine import get_session +from ..db.models import Finding, Scan +from ..db.queries import ( build_package_list_query, build_scan_list_query, get_dashboard_stats, @@ -206,8 +206,8 @@ async def analyze_finding_htmx( session: AsyncSession = Depends(get_session), ): """HTMX fragment: trigger LLM analysis and return styled result HTML.""" - from guarddog_nexus.config import config - from guarddog_nexus.llm import analyze_finding + from ..config import config + from ..core.llm import analyze_finding if not config.llm_enabled: return HTMLResponse( diff --git a/guarddog_nexus/webhooks.py b/guarddog_nexus/routes/webhooks.py similarity index 95% rename from guarddog_nexus/webhooks.py rename to guarddog_nexus/routes/webhooks.py index bb2c5d5..b56226c 100644 --- a/guarddog_nexus/webhooks.py +++ b/guarddog_nexus/routes/webhooks.py @@ -7,8 +7,8 @@ import re from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status -from guarddog_nexus.config import config -from guarddog_nexus.constants import ( +from ..config import config +from ..constants import ( DEFAULT_ECOSYSTEM, METADATA_PATTERNS, PACKAGE_EXTENSIONS, @@ -19,9 +19,9 @@ from guarddog_nexus.constants import ( WEBHOOK_STATUS_ACCEPTED, WEBHOOK_STATUS_IGNORED, ) -from guarddog_nexus.database import get_session -from guarddog_nexus.harvester import harvest -from guarddog_nexus.logging_setup import log +from ..core.harvester import harvest +from ..db.engine import get_session +from ..logging_setup import log router = APIRouter(prefix="/webhooks", tags=["webhooks"]) @@ -141,7 +141,7 @@ async def nexus_webhook( async def _scan_component(repository: str, name: str, version: str, ecosystem: str): - from guarddog_nexus.nexus_client import nexus_get + from ..core.nexus import nexus_get api_path = ( f"/service/rest/v1/search" diff --git a/tests/conftest.py b/tests/conftest.py index be65ca0..9d810fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,9 +18,9 @@ os.environ["LOG_SYSLOG_HOST"] = "" os.environ["TEMP_DIR"] = "/tmp/guarddog-nexus-test" from guarddog_nexus.constants import DEFAULT_ECOSYSTEM, SEVERITY_WARNING # noqa: E402 -from guarddog_nexus.database import Base, get_session # noqa: E402 +from guarddog_nexus.db.engine import Base, get_session # noqa: E402 +from guarddog_nexus.db.models import Finding, Scan, ScanStatus # noqa: E402 from guarddog_nexus.main import app # noqa: E402 -from guarddog_nexus.models import Finding, Scan, ScanStatus # noqa: E402 @pytest_asyncio.fixture diff --git a/tests/test_harvester.py b/tests/test_harvester.py index 0514300..19406f2 100644 --- a/tests/test_harvester.py +++ b/tests/test_harvester.py @@ -5,16 +5,16 @@ from unittest.mock import patch import pytest from sqlalchemy import select -from guarddog_nexus.harvester import harvest -from guarddog_nexus.models import Finding +from guarddog_nexus.core.harvester import harvest +from guarddog_nexus.db.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, + patch("guarddog_nexus.core.harvester.download_asset") as mock_dl, + patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha, + patch("guarddog_nexus.core.harvester.scan_package") as mock_scan, ): mock_dl.return_value = "/tmp/test-package.tar.gz" mock_sha.return_value = "abc123" @@ -50,9 +50,9 @@ async def test_harvest_new_package(db_session, guarddog_normalized_flagged): async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged): """Same SHA256 as existing scan → skip, don't re-scan.""" 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, + patch("guarddog_nexus.core.harvester.download_asset") as mock_dl, + patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha, + patch("guarddog_nexus.core.harvester.scan_package") as mock_scan, ): mock_dl.return_value = "/tmp/test.tar.gz" mock_sha.return_value = "deadbeef" @@ -86,9 +86,9 @@ async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged async def test_harvest_different_sha256_scans_again(db_session, guarddog_normalized_flagged): """Same name/version, different SHA256 → new scan.""" 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, + patch("guarddog_nexus.core.harvester.download_asset") as mock_dl, + patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha, + patch("guarddog_nexus.core.harvester.scan_package") as mock_scan, ): mock_dl.return_value = "/tmp/test.tar.gz" mock_scan.return_value = guarddog_normalized_flagged @@ -123,9 +123,9 @@ async def test_harvest_different_sha256_scans_again(db_session, guarddog_normali async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalized_flagged): """Concurrent webhooks for same URL: first proceeding, second skips as PENDING.""" 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, + patch("guarddog_nexus.core.harvester.download_asset") as mock_dl, + patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha, + patch("guarddog_nexus.core.harvester.scan_package") as mock_scan, ): mock_dl.return_value = "/tmp/test.tar.gz" mock_sha.return_value = "aaa" @@ -147,9 +147,9 @@ async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalize async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_flagged): """Same URL twice: second run hits SHA256 dedup (first already completed).""" 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, + patch("guarddog_nexus.core.harvester.download_asset") as mock_dl, + patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha, + patch("guarddog_nexus.core.harvester.scan_package") as mock_scan, ): mock_dl.return_value = "/tmp/test.tar.gz" mock_sha.return_value = "ccc" @@ -182,9 +182,9 @@ async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_fla @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, + patch("guarddog_nexus.core.harvester.download_asset") as mock_dl, + patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha, + patch("guarddog_nexus.core.harvester.scan_package") as mock_scan, ): mock_dl.return_value = "/tmp/test.tar.gz" mock_sha.return_value = "abc" @@ -205,7 +205,7 @@ async def test_harvest_clean_package(db_session, guarddog_normalized_clean): @pytest.mark.asyncio async def test_harvest_download_failure(db_session): - with patch("guarddog_nexus.harvester.download_asset") as mock_dl: + with patch("guarddog_nexus.core.harvester.download_asset") as mock_dl: mock_dl.return_value = None scan = await harvest( diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 39898fd..cc275fd 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1,6 +1,6 @@ """Tests for GuardDog scanner integration.""" -from guarddog_nexus.scanner import _normalize_output +from guarddog_nexus.core.scanner import _normalize_output def test_normalize_clean_output(guarddog_output_clean): diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index b81848c..ab3015f 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -34,7 +34,7 @@ async def test_webhook_ignores_created_action(client, sample_nexus_webhook): @pytest.mark.asyncio async def test_webhook_accepts_asset_updated(client, sample_nexus_webhook): sample_nexus_webhook["action"] = "UPDATED" - with patch("guarddog_nexus.webhooks._scan_in_background") as _mock: + with patch("guarddog_nexus.routes.webhooks._scan_in_background") as _mock: resp = await client.post("/webhooks/nexus", json=sample_nexus_webhook) assert resp.status_code == 200 assert resp.json()["status"] == "accepted" @@ -68,7 +68,7 @@ async def test_webhook_no_asset_or_component(client): @pytest.mark.asyncio async def test_webhook_accepts_component(client, sample_nexus_component_webhook): - with patch("guarddog_nexus.webhooks._scan_component") as _mock: + with patch("guarddog_nexus.routes.webhooks._scan_component") as _mock: resp = await client.post("/webhooks/nexus", json=sample_nexus_component_webhook) assert resp.status_code == 200 data = resp.json()