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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml README.md ./
|
# Install dependencies first for layer caching (source changes don't invalidate)
|
||||||
COPY guarddog_nexus/ guarddog_nexus/
|
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
|
||||||
RUN uv pip install --system "guarddog>=2.10.0"
|
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
|
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:
|
install:
|
||||||
pip install -e .
|
pip install -e .
|
||||||
@@ -16,6 +16,19 @@ format:
|
|||||||
ruff format guarddog_nexus tests
|
ruff format guarddog_nexus tests
|
||||||
ruff check --fix 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-build:
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
@@ -29,11 +42,20 @@ docker-destroy:
|
|||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
|
||||||
docker-rebuild:
|
docker-rebuild:
|
||||||
docker compose down && docker compose up -d --build
|
docker compose down; docker compose up -d --build
|
||||||
|
|
||||||
docker-logs:
|
docker-logs:
|
||||||
docker compose logs -f
|
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:
|
clean:
|
||||||
rm -rf dist build *.egg-info
|
rm -rf dist build *.egg-info
|
||||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ services:
|
|||||||
nexus-setup:
|
nexus-setup:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
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:
|
nexus:
|
||||||
image: sonatype/nexus3:3.79.0
|
image: sonatype/nexus3:3.79.0
|
||||||
@@ -32,6 +45,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nexus-data:/nexus-data
|
- nexus-data:/nexus-data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:8081/service/rest/v1/status"]
|
test: ["CMD", "curl", "-sf", "http://localhost:8081/service/rest/v1/status"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ _REPORT_DEFAULTS = {
|
|||||||
|
|
||||||
|
|
||||||
def _validate_report(report: dict) -> dict:
|
def _validate_report(report: dict) -> dict:
|
||||||
|
result = dict(report)
|
||||||
for field, default in _REPORT_DEFAULTS.items():
|
for field, default in _REPORT_DEFAULTS.items():
|
||||||
if not report.get(field):
|
if not result.get(field):
|
||||||
report[field] = default
|
result[field] = default
|
||||||
if report["verdict"] not in ("safe", "suspicious", "malicious", "unknown"):
|
if result["verdict"] not in ("safe", "suspicious", "malicious", "unknown"):
|
||||||
report["verdict"] = "unknown"
|
result["verdict"] = "unknown"
|
||||||
return report
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_user_message(finding: dict) -> str:
|
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:
|
async def download_asset(download_url: str, dest_dir: str) -> str | None:
|
||||||
"""Download an asset from Nexus using async httpx."""
|
"""Download an asset from Nexus using async httpx."""
|
||||||
if not _validate_download_url(download_url):
|
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
|
return None
|
||||||
|
|
||||||
dest_path = os.path.join(dest_dir, os.path.basename(download_url.split("?")[0]))
|
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 scan timestamp
|
||||||
latest = await session.scalar(select(func.max(Scan.started_at)))
|
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 = [
|
lines = [
|
||||||
"# HELP guarddog_scans_total Total number of package scans.",
|
"# HELP guarddog_scans_total Total number of package scans.",
|
||||||
"# TYPE guarddog_scans_total counter",
|
"# TYPE guarddog_scans_total counter",
|
||||||
@@ -46,6 +59,14 @@ async def metrics(session: AsyncSession = Depends(get_session)) -> Response:
|
|||||||
"# TYPE guarddog_findings_total counter",
|
"# TYPE guarddog_findings_total counter",
|
||||||
f"guarddog_findings_total {findings_total}",
|
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.",
|
"# HELP guarddog_scans_by_status Scans grouped by status.",
|
||||||
"# TYPE guarddog_scans_by_status gauge",
|
"# TYPE guarddog_scans_by_status gauge",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ def _render(name: str, **context) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_flagged(value: str) -> bool | None:
|
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)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -247,3 +247,52 @@ async def test_health_no_db_leak(client):
|
|||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
resp = await client.get("/health")
|
resp = await client.get("/health")
|
||||||
assert resp.status_code == 200
|
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,
|
db_session,
|
||||||
)
|
)
|
||||||
assert scan is None
|
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()
|
assert "failed" in resp.text.lower()
|
||||||
|
|
||||||
guarddog_nexus.config.config.llm_enabled = False
|
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):
|
def test_unknown_ecosystem(self):
|
||||||
assert extract_package_info("/packages/pkg/1.0/file.tar.gz", "unknown") == ("pkg", "1.0")
|
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
|
assert resp.status_code == 403
|
||||||
|
|
||||||
guarddog_nexus.config.config.webhook_secret = ""
|
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