feat: 31 new tests, metrics LLM counters, Dockerfile caching, Makefile targets, compose limits, code fixes
This commit is contained in:
17
.pre-commit-config.yaml
Normal file
17
.pre-commit-config.yaml
Normal file
@@ -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
|
||||
27
.tmp/tasks/review-fixes/subtask_01.json
Normal file
27
.tmp/tasks/review-fixes/subtask_01.json
Normal file
@@ -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
|
||||
}
|
||||
28
.tmp/tasks/review-fixes/subtask_02.json
Normal file
28
.tmp/tasks/review-fixes/subtask_02.json
Normal file
@@ -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
|
||||
}
|
||||
30
.tmp/tasks/review-fixes/subtask_03.json
Normal file
30
.tmp/tasks/review-fixes/subtask_03.json
Normal file
@@ -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
|
||||
}
|
||||
31
.tmp/tasks/review-fixes/subtask_04.json
Normal file
31
.tmp/tasks/review-fixes/subtask_04.json
Normal file
@@ -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
|
||||
}
|
||||
30
.tmp/tasks/review-fixes/subtask_05.json
Normal file
30
.tmp/tasks/review-fixes/subtask_05.json
Normal file
@@ -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
|
||||
}
|
||||
29
.tmp/tasks/review-fixes/subtask_06.json
Normal file
29
.tmp/tasks/review-fixes/subtask_06.json
Normal file
@@ -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
|
||||
}
|
||||
32
.tmp/tasks/review-fixes/subtask_07.json
Normal file
32
.tmp/tasks/review-fixes/subtask_07.json
Normal file
@@ -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
|
||||
}
|
||||
31
.tmp/tasks/review-fixes/subtask_08.json
Normal file
31
.tmp/tasks/review-fixes/subtask_08.json
Normal file
@@ -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
|
||||
}
|
||||
33
.tmp/tasks/review-fixes/subtask_09.json
Normal file
33
.tmp/tasks/review-fixes/subtask_09.json
Normal file
@@ -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
|
||||
}
|
||||
35
.tmp/tasks/review-fixes/task.json
Normal file
35
.tmp/tasks/review-fixes/task.json
Normal file
@@ -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
|
||||
}
|
||||
11
Dockerfile
11
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
|
||||
|
||||
|
||||
26
Makefile
26
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
34
tests/test_config.py
Normal file
34
tests/test_config.py
Normal file
@@ -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"]
|
||||
59
tests/test_engine.py
Normal file
59
tests/test_engine.py
Normal file
@@ -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"}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
78
tests/test_schemas.py
Normal file
78
tests/test_schemas.py
Normal file
@@ -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"] == ""
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user