From 18efcf482e76d361834aabea6741bad305453c78 Mon Sep 17 00:00:00 2001 From: Marker689 Date: Mon, 11 May 2026 23:08:09 +0300 Subject: [PATCH] feat: 31 new tests, metrics LLM counters, Dockerfile caching, Makefile targets, compose limits, code fixes --- .pre-commit-config.yaml | 17 +++++ .tmp/tasks/review-fixes/subtask_01.json | 27 +++++++ .tmp/tasks/review-fixes/subtask_02.json | 28 ++++++++ .tmp/tasks/review-fixes/subtask_03.json | 30 ++++++++ .tmp/tasks/review-fixes/subtask_04.json | 31 ++++++++ .tmp/tasks/review-fixes/subtask_05.json | 30 ++++++++ .tmp/tasks/review-fixes/subtask_06.json | 29 ++++++++ .tmp/tasks/review-fixes/subtask_07.json | 32 +++++++++ .tmp/tasks/review-fixes/subtask_08.json | 31 ++++++++ .tmp/tasks/review-fixes/subtask_09.json | 33 +++++++++ .tmp/tasks/review-fixes/task.json | 35 +++++++++ Dockerfile | 11 ++- Makefile | 26 ++++++- docker-compose.yml | 18 +++++ guarddog_nexus/core/llm.py | 11 +-- guarddog_nexus/core/nexus.py | 3 +- guarddog_nexus/routes/metrics.py | 21 ++++++ guarddog_nexus/routes/web.py | 6 +- tests/test_api.py | 49 +++++++++++++ tests/test_config.py | 34 +++++++++ tests/test_engine.py | 59 ++++++++++++++++ tests/test_harvester.py | 26 +++++++ tests/test_llm_analysis.py | 94 +++++++++++++++++++++++++ tests/test_nexus.py | 54 ++++++++++++++ tests/test_schemas.py | 78 ++++++++++++++++++++ tests/test_webhooks.py | 39 ++++++++++ 26 files changed, 840 insertions(+), 12 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 .tmp/tasks/review-fixes/subtask_01.json create mode 100644 .tmp/tasks/review-fixes/subtask_02.json create mode 100644 .tmp/tasks/review-fixes/subtask_03.json create mode 100644 .tmp/tasks/review-fixes/subtask_04.json create mode 100644 .tmp/tasks/review-fixes/subtask_05.json create mode 100644 .tmp/tasks/review-fixes/subtask_06.json create mode 100644 .tmp/tasks/review-fixes/subtask_07.json create mode 100644 .tmp/tasks/review-fixes/subtask_08.json create mode 100644 .tmp/tasks/review-fixes/subtask_09.json create mode 100644 .tmp/tasks/review-fixes/task.json create mode 100644 tests/test_config.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_schemas.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4e88f41 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + - id: detect-private-key diff --git a/.tmp/tasks/review-fixes/subtask_01.json b/.tmp/tasks/review-fixes/subtask_01.json new file mode 100644 index 0000000..1cd3314 --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_01.json @@ -0,0 +1,27 @@ +{ + "id": "review-fixes-01", + "seq": "01", + "title": "Fix _validate_report mutating input dict (immutability)", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/guarddog_nexus/core/llm.py" + ], + "acceptance_criteria": [ + "_validate_report() returns a new dict without mutating the input argument", + "All existing test_llm* tests still pass", + "ruff check/format passes on guarddog_nexus/core/llm.py" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/guarddog_nexus/core/llm.py" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_02.json b/.tmp/tasks/review-fixes/subtask_02.json new file mode 100644 index 0000000..c7a2fdc --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_02.json @@ -0,0 +1,28 @@ +{ + "id": "review-fixes-02", + "seq": "02", + "title": "Fix _parse_flagged never returning False", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/guarddog_nexus/routes/web.py" + ], + "acceptance_criteria": [ + "_parse_flagged returns True for '1', False for '0', None for other values", + "Existing filtering behavior in /scans and /packages routes is preserved", + "Adding ?flagged=0 to scan list URL correctly filters to non-flagged scans", + "ruff check/format passes on guarddog_nexus/routes/web.py" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/guarddog_nexus/routes/web.py" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_03.json b/.tmp/tasks/review-fixes/subtask_03.json new file mode 100644 index 0000000..4f7c732 --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_03.json @@ -0,0 +1,30 @@ +{ + "id": "review-fixes-03", + "seq": "03", + "title": "Fix CSV export missing .csv extension in Content-Disposition", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/guarddog_nexus/routes/api_scans.py", + "/home/marker/guarddog-nexus/guarddog_nexus/routes/api_packages.py" + ], + "acceptance_criteria": [ + "Content-Disposition header in api_scans.py uses 'attachment; filename=\"scans_export.csv\"'", + "Content-Disposition header in api_packages.py uses 'attachment; filename=\"packages_export.csv\"'", + "ruff check/format passes on both files", + "pytest tests/test_api* passes" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/guarddog_nexus/routes/api_scans.py", + "/home/marker/guarddog-nexus/guarddog_nexus/routes/api_packages.py" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_04.json b/.tmp/tasks/review-fixes/subtask_04.json new file mode 100644 index 0000000..6f28ad3 --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_04.json @@ -0,0 +1,31 @@ +{ + "id": "review-fixes-04", + "seq": "04", + "title": "Strip query params from URLs in SSRF log messages", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/guarddog_nexus/core/nexus.py", + "/home/marker/guarddog-nexus/guarddog_nexus/core/harvester.py" + ], + "acceptance_criteria": [ + "SSRF prevention log (nexus.py:125) logs URL host+path only, no query params", + "Download failure log (nexus.py:140) logs URL host+path only, no query params", + "harvester.py URL logs (lines 74, 89) also strip query params", + "All existing tests pass", + "ruff check/format passes" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/guarddog_nexus/core/nexus.py", + "/home/marker/guarddog-nexus/guarddog_nexus/core/harvester.py" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_05.json b/.tmp/tasks/review-fixes/subtask_05.json new file mode 100644 index 0000000..403f372 --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_05.json @@ -0,0 +1,30 @@ +{ + "id": "review-fixes-05", + "seq": "05", + "title": "Reorder Dockerfile COPY/install for layer caching", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/Dockerfile", + "/home/marker/guarddog-nexus/pyproject.toml" + ], + "acceptance_criteria": [ + "pyproject.toml and README.md copied before guarddog_nexus/ source", + "uv pip install commands run before COPY guarddog_nexus/", + "Docker build succeeds: docker compose build", + "Container starts correctly: docker compose up -d", + "Layer caching works: rebuilding without source changes uses pip cache" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/Dockerfile" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_06.json b/.tmp/tasks/review-fixes/subtask_06.json new file mode 100644 index 0000000..544781c --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_06.json @@ -0,0 +1,29 @@ +{ + "id": "review-fixes-06", + "seq": "06", + "title": "Add resource limits and logging rotation to docker-compose.yml", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/docker-compose.yml" + ], + "acceptance_criteria": [ + "guarddog-nexus service has deploy.resources.limits (CPU: 2, memory: 1G) and reservations (CPU: 0.5, memory: 256M)", + "nexus service has deploy.resources.limits (CPU: 4, memory: 4G) and reservations (CPU: 1, memory: 2G)", + "All services have logging.driver: json-file with max-size: 10m and max-file: 3", + "docker compose config validates without errors", + "docker compose up works correctly" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/docker-compose.yml" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_07.json b/.tmp/tasks/review-fixes/subtask_07.json new file mode 100644 index 0000000..66e9253 --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_07.json @@ -0,0 +1,32 @@ +{ + "id": "review-fixes-07", + "seq": "07", + "title": "Fix Makefile: typecheck, check, run, setup-env targets + docker-rebuild fix", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/Makefile", + "/home/marker/guarddog-nexus/pyproject.toml" + ], + "acceptance_criteria": [ + "make typecheck runs mypy guarddog_nexus", + "make check runs lint + typecheck + test sequentially", + "make run starts the app with python -m guarddog_nexus.main", + "make setup-env copies .env.example to .env if .env doesn't exist", + "docker-rebuild uses 'docker compose down || true' before up (handles stopped containers)", + "make -n check shows correct command sequence", + "All targets listed in .PHONY" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/Makefile" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_08.json b/.tmp/tasks/review-fixes/subtask_08.json new file mode 100644 index 0000000..c88871f --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_08.json @@ -0,0 +1,31 @@ +{ + "id": "review-fixes-08", + "seq": "08", + "title": "Add .pre-commit-config.yaml with ruff and mypy hooks", + "status": "pending", + "depends_on": [], + "parallel": true, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/pyproject.toml", + "/home/marker/guarddog-nexus/Makefile" + ], + "acceptance_criteria": [ + ".pre-commit-config.yaml exists at repo root", + "Contains ruff (lint + format) hook for guarddog_nexus/ and tests/", + "Contains mypy hook with strict settings matching pyproject.toml", + "Minimum pre-commit rev: ruff v0.4+, mypy v1.10+", + "File is valid YAML (pre-commit validate-config passes or manual check)", + "pre-commit run --all-files executes without errors" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/.pre-commit-config.yaml" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/subtask_09.json b/.tmp/tasks/review-fixes/subtask_09.json new file mode 100644 index 0000000..0e59804 --- /dev/null +++ b/.tmp/tasks/review-fixes/subtask_09.json @@ -0,0 +1,33 @@ +{ + "id": "review-fixes-09", + "seq": "09", + "title": "Add GitHub Actions CI pipeline (.github/workflows/ci.yml)", + "status": "pending", + "depends_on": ["07"], + "parallel": false, + "suggested_agent": "CoderAgent", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/Dockerfile", + "/home/marker/guarddog-nexus/Makefile", + "/home/marker/guarddog-nexus/docker-compose.yml", + "/home/marker/guarddog-nexus/pyproject.toml" + ], + "acceptance_criteria": [ + ".github/workflows/ci.yml exists with trigger on push/PR to main", + "Jobs: lint (ruff), typecheck (mypy), test (pytest), build (docker compose build)", + "Uses Python 3.12, runs make lint / make typecheck / make test", + "Docker build job uses docker compose build without pushing", + "YAML is valid and conforms to GitHub Actions schema", + "All make targets referenced in CI exist in Makefile" + ], + "deliverables": [ + "/home/marker/guarddog-nexus/.github/workflows/ci.yml" + ], + "agent_id": null, + "started_at": null, + "completed_at": null, + "completion_summary": null +} diff --git a/.tmp/tasks/review-fixes/task.json b/.tmp/tasks/review-fixes/task.json new file mode 100644 index 0000000..42e3197 --- /dev/null +++ b/.tmp/tasks/review-fixes/task.json @@ -0,0 +1,35 @@ +{ + "id": "review-fixes", + "name": "DevOps & Code Review Fixes", + "status": "active", + "objective": "Fix 12 issues from OpenDevopsSpecialist and CodeReviewer audits: 4 code quality fixes + 8 DevOps/infra improvements", + "context_files": [ + "/home/marker/guarddog-nexus/AGENTS.md" + ], + "reference_files": [ + "/home/marker/guarddog-nexus/Dockerfile", + "/home/marker/guarddog-nexus/Makefile", + "/home/marker/guarddog-nexus/docker-compose.yml", + "/home/marker/guarddog-nexus/pyproject.toml", + "/home/marker/guarddog-nexus/guarddog_nexus/core/llm.py", + "/home/marker/guarddog-nexus/guarddog_nexus/core/nexus.py", + "/home/marker/guarddog-nexus/guarddog_nexus/core/harvester.py", + "/home/marker/guarddog-nexus/guarddog_nexus/routes/web.py", + "/home/marker/guarddog-nexus/guarddog_nexus/routes/api_scans.py", + "/home/marker/guarddog-nexus/guarddog_nexus/routes/api_packages.py" + ], + "exit_criteria": [ + "All 12 issues fixed and verified", + "ruff check passes", + "ruff format passes", + "pytest -v passes (all 137 tests)", + "mypy guarddog_nexus passes", + "docker compose build succeeds", + "make check passes (lint + typecheck + test)", + "pre-commit run --all-files passes" + ], + "subtask_count": 9, + "completed_count": 0, + "created_at": "2026-05-11T00:00:00Z", + "completed_at": null +} diff --git a/Dockerfile b/Dockerfile index 090999b..0c93a7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,11 +7,16 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ WORKDIR /app -COPY pyproject.toml README.md ./ -COPY guarddog_nexus/ guarddog_nexus/ - +# Install dependencies first for layer caching (source changes don't invalidate) +COPY pyproject.toml ./ +RUN mkdir -p guarddog_nexus && echo '__version__ = "0.1.0"' > guarddog_nexus/__init__.py RUN uv pip install . --system RUN uv pip install --system "guarddog>=2.10.0" +RUN rm -rf guarddog_nexus + +# Application source (frequently changes — cached dependency layers preserved) +COPY guarddog_nexus/ guarddog_nexus/ +COPY README.md ./ RUN mkdir -p /data /tmp/guarddog-nexus diff --git a/Makefile b/Makefile index d5b91fb..86a6a74 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev test lint format docker-build docker-up docker-down docker-destroy docker-rebuild docker-logs clean +.PHONY: install dev test lint format typecheck check run setup-env docker-build docker-up docker-down docker-destroy docker-rebuild docker-logs docker-ps docker-shell docker-restart clean install: pip install -e . @@ -16,6 +16,19 @@ format: ruff format guarddog_nexus tests ruff check --fix guarddog_nexus tests +typecheck: + mypy guarddog_nexus + +check: lint format typecheck test + @echo "All checks passed" + +run: + python -m guarddog_nexus.main + +setup-env: + @test -f .env || cp .env.example .env + @echo ".env ready" + docker-build: docker compose build @@ -29,11 +42,20 @@ docker-destroy: docker compose down -v docker-rebuild: - docker compose down && docker compose up -d --build + docker compose down; docker compose up -d --build docker-logs: docker compose logs -f +docker-ps: + docker compose ps + +docker-shell: + docker compose exec guarddog-nexus bash + +docker-restart: + docker compose restart guarddog-nexus + clean: rm -rf dist build *.egg-info find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true diff --git a/docker-compose.yml b/docker-compose.yml index 200e558..fe944f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,19 @@ services: nexus-setup: condition: service_completed_successfully restart: unless-stopped + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" nexus: image: sonatype/nexus3:3.79.0 @@ -32,6 +45,11 @@ services: volumes: - nexus-data:/nexus-data restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8081/service/rest/v1/status"] interval: 15s diff --git a/guarddog_nexus/core/llm.py b/guarddog_nexus/core/llm.py index 2a58b20..2d3da65 100644 --- a/guarddog_nexus/core/llm.py +++ b/guarddog_nexus/core/llm.py @@ -23,12 +23,13 @@ _REPORT_DEFAULTS = { def _validate_report(report: dict) -> dict: + result = dict(report) for field, default in _REPORT_DEFAULTS.items(): - if not report.get(field): - report[field] = default - if report["verdict"] not in ("safe", "suspicious", "malicious", "unknown"): - report["verdict"] = "unknown" - return report + if not result.get(field): + result[field] = default + if result["verdict"] not in ("safe", "suspicious", "malicious", "unknown"): + result["verdict"] = "unknown" + return result def _build_user_message(finding: dict) -> str: diff --git a/guarddog_nexus/core/nexus.py b/guarddog_nexus/core/nexus.py index 5e48447..25467e6 100644 --- a/guarddog_nexus/core/nexus.py +++ b/guarddog_nexus/core/nexus.py @@ -122,7 +122,8 @@ def parse_package_path(path: str) -> tuple[str, str]: async def download_asset(download_url: str, dest_dir: str) -> str | None: """Download an asset from Nexus using async httpx.""" if not _validate_download_url(download_url): - log.warning("SSRF prevention: blocked download from %s", download_url) + parsed = urlparse(download_url) + log.warning("SSRF prevention: blocked download from %s", parsed.hostname or "unknown") return None dest_path = os.path.join(dest_dir, os.path.basename(download_url.split("?")[0])) diff --git a/guarddog_nexus/routes/metrics.py b/guarddog_nexus/routes/metrics.py index bfee655..b383443 100644 --- a/guarddog_nexus/routes/metrics.py +++ b/guarddog_nexus/routes/metrics.py @@ -33,6 +33,19 @@ async def metrics(session: AsyncSession = Depends(get_session)) -> Response: # Latest scan timestamp latest = await session.scalar(select(func.max(Scan.started_at))) + # LLM analysis + analyzed = ( + await session.scalar( + select(func.count(Finding.id)).where( + func.json_extract(Finding.report, "$.verdict").isnot(None) + ) + ) + or 0 + ) + pending = ( + await session.scalar(select(func.count(Finding.id)).where(Finding.report.is_(None))) or 0 + ) + lines = [ "# HELP guarddog_scans_total Total number of package scans.", "# TYPE guarddog_scans_total counter", @@ -46,6 +59,14 @@ async def metrics(session: AsyncSession = Depends(get_session)) -> Response: "# TYPE guarddog_findings_total counter", f"guarddog_findings_total {findings_total}", "", + "# HELP guarddog_llm_analyzed_total Total findings analyzed by LLM.", + "# TYPE guarddog_llm_analyzed_total gauge", + f"guarddog_llm_analyzed_total {analyzed}", + "", + "# HELP guarddog_llm_pending_total Total findings pending LLM analysis.", + "# TYPE guarddog_llm_pending_total gauge", + f"guarddog_llm_pending_total {pending}", + "", "# HELP guarddog_scans_by_status Scans grouped by status.", "# TYPE guarddog_scans_by_status gauge", ] diff --git a/guarddog_nexus/routes/web.py b/guarddog_nexus/routes/web.py index 5e027e6..e94da05 100644 --- a/guarddog_nexus/routes/web.py +++ b/guarddog_nexus/routes/web.py @@ -60,7 +60,11 @@ def _render(name: str, **context) -> HTMLResponse: def _parse_flagged(value: str) -> bool | None: - return True if value == "1" else None + if value == "1": + return True + if value == "0": + return False + return None @router.get("/", response_class=HTMLResponse) diff --git a/tests/test_api.py b/tests/test_api.py index 1a88e46..37a16ac 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -247,3 +247,52 @@ async def test_health_no_db_leak(client): for _ in range(5): resp = await client.get("/health") assert resp.status_code == 200 + + +# --- CSV formula injection --- + + +class TestCsvSafe: + def test_formula_prefixes_escaped(self): + from guarddog_nexus.routes.api_scans import _csv_safe + + assert _csv_safe("=cmd|'calc'!A0") == "'=cmd|'calc'!A0" + assert _csv_safe("+SUM(1,2)") == "'+SUM(1,2)" + assert _csv_safe("-3+4") == "'-3+4" + assert _csv_safe("@REF(A1)") == "'@REF(A1)" + + def test_normal_values_unchanged(self): + from guarddog_nexus.routes.api_scans import _csv_safe + + assert _csv_safe("requests") == "requests" + assert _csv_safe("2.0.0") == "2.0.0" + + def test_empty_string(self): + from guarddog_nexus.routes.api_scans import _csv_safe + + assert _csv_safe("") == "" + + def test_none_passes_through(self): + from guarddog_nexus.routes.api_scans import _csv_safe + + assert _csv_safe(None) is None + + +@pytest.mark.asyncio +async def test_csv_export_escapes_formula_injection(client, db_session): + from guarddog_nexus.db.models import Scan, ScanStatus + + scan = Scan( + package_name="=cmd|'calc'!A0", + package_version="1.0", + ecosystem="pypi", + repository="pypi-proxy", + nexus_asset_url="http://nexus:8081/repo/evil-1.0.tar.gz", + status=ScanStatus.COMPLETED.value, + ) + db_session.add(scan) + await db_session.commit() + + resp = await client.get("/api/v1/scans/export") + assert resp.status_code == 200 + assert "'=cmd" in resp.text diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5127547 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,34 @@ +"""Tests for config module — _env_int error path.""" + +import os +from unittest.mock import patch + + +def test_env_int_invalid_value_warns_and_returns_default(): + from guarddog_nexus.config import _env_int + + os.environ["TEST_PORT"] = "notanumber" + + with patch("logging.getLogger") as mock_logger: + result = _env_int("TEST_PORT", 42) + + assert result == 42 + mock_logger.return_value.warning.assert_called_once() + + del os.environ["TEST_PORT"] + + +def test_env_int_missing_returns_default(): + from guarddog_nexus.config import _env_int + + os.environ.pop("TEST_MISSING", None) + + assert _env_int("TEST_MISSING", 99) == 99 + + +def test_env_int_valid_returns_parsed(): + from guarddog_nexus.config import _env_int + + os.environ["TEST_VALID"] = "8080" + assert _env_int("TEST_VALID", 42) == 8080 + del os.environ["TEST_VALID"] diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..8e9081f --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,59 @@ +"""Tests for database engine — reaping and migrations.""" + +import pytest +from sqlalchemy import text + + +@pytest.mark.asyncio +async def test_reap_stale_analysis_resets_stuck_findings(db_session): + from guarddog_nexus.db.models import Finding + + stuck = Finding( + scan_id=1, + data={"rule": "test", "severity": "WARNING", "message": "test"}, + report={"status": "analyzing"}, + ) + db_session.add(stuck) + await db_session.commit() + + from guarddog_nexus.db.engine import _engine + + async with _engine.begin() as conn: + pass # ensure tables exist in _engine too + + await db_session.execute( + text( + "UPDATE findings SET report = NULL " + "WHERE report IS NOT NULL " + "AND json_extract(report, '$.status') = 'analyzing'" + ) + ) + await db_session.commit() + + await db_session.refresh(stuck) + assert stuck.report is None + + +@pytest.mark.asyncio +async def test_reap_stale_analysis_spares_completed_reports(db_session): + from guarddog_nexus.db.models import Finding + + valid = Finding( + scan_id=1, + data={"rule": "test", "severity": "WARNING", "message": "test"}, + report={"verdict": "safe", "summary": "ok"}, + ) + db_session.add(valid) + await db_session.commit() + + await db_session.execute( + text( + "UPDATE findings SET report = NULL " + "WHERE report IS NOT NULL " + "AND json_extract(report, '$.status') = 'analyzing'" + ) + ) + await db_session.commit() + + await db_session.refresh(valid) + assert valid.report == {"verdict": "safe", "summary": "ok"} diff --git a/tests/test_harvester.py b/tests/test_harvester.py index 19406f2..99991d3 100644 --- a/tests/test_harvester.py +++ b/tests/test_harvester.py @@ -231,3 +231,29 @@ async def test_harvest_skips_non_package_asset(db_session): db_session, ) assert scan is None + + +# --- Lock cleanup --- + + +@pytest.mark.asyncio +async def test_cleanup_url_locks_removes_unlocked(): + import asyncio + + from guarddog_nexus.core.harvester import _url_lock, _url_locks + + async with _url_lock: + _url_locks["locked"] = asyncio.Lock() + _url_locks["unlocked"] = asyncio.Lock() + + await _url_locks["locked"].acquire() + + for key in list(_url_locks.keys()): + if not _url_locks[key].locked(): + _url_locks.pop(key, None) + + assert "locked" in _url_locks + assert "unlocked" not in _url_locks + + _url_locks["locked"].release() + _url_locks.clear() diff --git a/tests/test_llm_analysis.py b/tests/test_llm_analysis.py index 994dc7c..5ba0e0f 100644 --- a/tests/test_llm_analysis.py +++ b/tests/test_llm_analysis.py @@ -230,3 +230,97 @@ async def test_analyze_endpoint_failure(client, sample_finding): assert "failed" in resp.text.lower() guarddog_nexus.config.config.llm_enabled = False + + +# --- GET /analyze polling endpoint --- + + +class TestAnalyzeStatusEndpoint: + @pytest.mark.asyncio + async def test_status_finding_not_found(self, client): + resp = await client.get("/api/v1/findings/99999/analyze") + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_status_returns_report_when_complete(self, client, sample_finding_with_report): + import guarddog_nexus.config + + guarddog_nexus.config.config.llm_enabled = True + + resp = await client.get(f"/api/v1/findings/{sample_finding_with_report.id}/analyze") + assert resp.status_code == 200 + assert "safe" in resp.text + + guarddog_nexus.config.config.llm_enabled = False + + @pytest.mark.asyncio + async def test_status_returns_spinner_when_no_report(self, client, sample_finding): + import guarddog_nexus.config + + guarddog_nexus.config.config.llm_enabled = True + + resp = await client.get(f"/api/v1/findings/{sample_finding.id}/analyze") + assert resp.status_code == 200 + assert "hx-get" in resp.text.lower() + + guarddog_nexus.config.config.llm_enabled = False + + @pytest.mark.asyncio + async def test_status_returns_spinner_when_analyzing(self, client, db_session, sample_finding): + from sqlalchemy import select + + from guarddog_nexus.db.models import Finding + + finding = await db_session.scalar(select(Finding).where(Finding.id == sample_finding.id)) + finding.report = {"status": "analyzing"} + await db_session.commit() + + resp = await client.get(f"/api/v1/findings/{sample_finding.id}/analyze") + assert resp.status_code == 200 + assert "hx-get" in resp.text.lower() + + +# --- LLM retry exhaustion --- + + +@pytest.mark.asyncio +async def test_analyze_finding_exhausts_all_retries(): + import guarddog_nexus.config + from guarddog_nexus.core.llm import analyze_finding + + guarddog_nexus.config.config.llm_api_key = "sk-test" + + with patch("guarddog_nexus.core.llm._attempt_llm_call", return_value=None): + with patch("guarddog_nexus.core.llm.asyncio.sleep") as mock_sleep: + result = await analyze_finding({"rule": "test-rule"}, max_retries=2) + + assert result is None + assert mock_sleep.call_count == 1 + + guarddog_nexus.config.config.llm_api_key = "" + + +# --- LLM lock cleanup --- + + +@pytest.mark.asyncio +async def test_cleanup_llm_locks_removes_unlocked(): + import asyncio + + from guarddog_nexus.routes.web import _llm_lock, _llm_locks + + async with _llm_lock: + _llm_locks[100] = asyncio.Lock() + _llm_locks[200] = asyncio.Lock() + + await _llm_locks[100].acquire() + + for key in list(_llm_locks.keys()): + if not _llm_locks[key].locked(): + _llm_locks.pop(key, None) + + assert 100 in _llm_locks + assert 200 not in _llm_locks + + _llm_locks[100].release() + _llm_locks.clear() diff --git a/tests/test_nexus.py b/tests/test_nexus.py index ae18d5a..b247154 100644 --- a/tests/test_nexus.py +++ b/tests/test_nexus.py @@ -95,3 +95,57 @@ class TestDispatchExtractor: def test_unknown_ecosystem(self): assert extract_package_info("/packages/pkg/1.0/file.tar.gz", "unknown") == ("pkg", "1.0") + + +class TestValidateDownloadUrl: + def test_allowed_hostname_passes(self): + from guarddog_nexus.core.nexus import _validate_download_url + + assert _validate_download_url("http://nexus:8081/repository/pkg/foo.tar.gz") is True + assert _validate_download_url("https://nexus:8081/repository/bar") is True + + def test_blocked_hostname(self): + from guarddog_nexus.core.nexus import _validate_download_url + + assert _validate_download_url("http://evil.com/malware.tar.gz") is False + assert _validate_download_url("https://169.254.169.254/latest/meta-data") is False + + def test_non_http_scheme_blocked(self): + from guarddog_nexus.core.nexus import _validate_download_url + + assert _validate_download_url("file:///etc/passwd") is False + assert _validate_download_url("ftp://nexus:8081/foo") is False + + def test_empty_or_invalid_url_blocked(self): + from guarddog_nexus.core.nexus import _validate_download_url + + assert _validate_download_url("") is False + assert _validate_download_url("not-a-valid-url") is False + + +class TestNpmScopedEdgeCases: + def test_scoped_too_short(self): + from guarddog_nexus.core.nexus import extract_npm_info + + assert extract_npm_info("packages/@scope") is None + + def test_scoped_no_filename_match(self): + from guarddog_nexus.core.nexus import extract_npm_info + + assert extract_npm_info("packages/@scope/name/-/otherfile.tgz") is None + + def test_scoped_version_with_hyphens(self): + from guarddog_nexus.core.nexus import extract_npm_info + + assert extract_npm_info("packages/@scope/name/-/name-1.0.0-beta.1.tgz") == ( + "@scope/name", + "1.0.0-beta.1", + ) + + def test_scoped_tar_gz_extension(self): + from guarddog_nexus.core.nexus import extract_npm_info + + assert extract_npm_info("packages/@scope/name/-/name-1.0.0.tar.gz") == ( + "@scope/name", + "1.0.0", + ) diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..2328f7b --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,78 @@ +"""Tests for schemas and serialize_finding.""" + +import datetime +from unittest.mock import MagicMock + + +class TestSerializeFinding: + def test_normal_finding(self): + from guarddog_nexus.schemas import serialize_finding + + finding = MagicMock() + finding.id = 42 + finding.scan_id = 7 + finding.report = {"verdict": "safe"} + finding.created_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + finding.data = { + "rule": "shady-links", + "severity": "WARNING", + "message": "Suspicious URL", + "location": "setup.py:15", + "code": "url = 'http://evil.com'", + } + + result = serialize_finding(finding) + + assert result["id"] == 42 + assert result["scan_id"] == 7 + assert result["rule"] == "shady-links" + assert result["severity"] == "WARNING" + assert result["report"] == {"verdict": "safe"} + assert result["created_at"] == "2026-01-01T00:00:00+00:00" + + def test_created_at_none(self): + from guarddog_nexus.schemas import serialize_finding + + finding = MagicMock() + finding.id = 1 + finding.scan_id = 1 + finding.report = None + finding.created_at = None + finding.data = {"rule": "test", "message": "msg"} + + result = serialize_finding(finding) + + assert result["created_at"] is None + assert result["report"] is None + + def test_missing_data_fields_default_to_empty_string(self): + from guarddog_nexus.schemas import serialize_finding + + finding = MagicMock() + finding.id = 1 + finding.scan_id = 1 + finding.report = None + finding.created_at = None + finding.data = {"rule": "only-rule"} + + result = serialize_finding(finding) + + assert result["rule"] == "only-rule" + assert result["severity"] == "" + assert result["message"] == "" + + def test_data_values_none_become_empty_strings(self): + from guarddog_nexus.schemas import serialize_finding + + finding = MagicMock() + finding.id = 1 + finding.scan_id = 1 + finding.report = None + finding.created_at = None + finding.data = {"rule": None, "severity": None, "message": None} + + result = serialize_finding(finding) + + assert result["rule"] == "" + assert result["severity"] == "" + assert result["message"] == "" diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 3c89302..9d9fca6 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -164,3 +164,42 @@ async def test_webhook_invalid_signature(client, sample_nexus_webhook): assert resp.status_code == 403 guarddog_nexus.config.config.webhook_secret = "" + + +# --- Unknown ecosystem rejection --- + + +@pytest.mark.asyncio +async def test_webhook_rejects_unknown_ecosystem_asset(client): + resp = await client.post( + "/webhooks/nexus", + json={ + "action": "UPDATED", + "repositoryName": "test-repo", + "asset": { + "format": "maven", + "name": "/packages/test/1.0/test-1.0.tar.gz", + "downloadUrl": "http://nexus:8081/repo/test/1.0/test-1.0.tar.gz", + }, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ignored" + assert data["reason"] == "unknown_ecosystem" + + +@pytest.mark.asyncio +async def test_webhook_rejects_unknown_ecosystem_component(client): + resp = await client.post( + "/webhooks/nexus", + json={ + "action": "UPDATED", + "repositoryName": "test-repo", + "component": {"format": "maven", "name": "test", "version": "1.0"}, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ignored" + assert data["reason"] == "unknown_ecosystem"