diff --git a/guarddog_nexus/api/findings.py b/guarddog_nexus/api/findings.py index 255e3d1..f78af6b 100644 --- a/guarddog_nexus/api/findings.py +++ b/guarddog_nexus/api/findings.py @@ -21,9 +21,9 @@ async def list_findings( ): q = select(Finding) if rule: - q = q.where(Finding.rule == rule) + q = q.where(func.json_extract(Finding.data, "$.rule") == rule) if severity: - q = q.where(Finding.severity == severity) + q = q.where(func.json_extract(Finding.data, "$.severity") == severity) if scan_id: q = q.where(Finding.scan_id == scan_id) @@ -38,10 +38,7 @@ async def list_findings( { "id": f.id, "scan_id": f.scan_id, - "rule": f.rule, - "severity": f.severity, - "message": f.message, - "location": f.location, + **f.data, "created_at": f.created_at.isoformat() if f.created_at else None, } for f in findings diff --git a/guarddog_nexus/api/packages.py b/guarddog_nexus/api/packages.py index 92cebd2..c3cb9af 100644 --- a/guarddog_nexus/api/packages.py +++ b/guarddog_nexus/api/packages.py @@ -84,12 +84,13 @@ async def get_package( if not scans: return {"detail": "Not found"} - all_findings = [] + all_findings: list[dict] = [] for s in scans: findings = ( (await session.execute(select(Finding).where(Finding.scan_id == s.id))).scalars().all() ) - all_findings.extend(f.__dict__ for f in findings) + for f in findings: + all_findings.append({"id": f.id, **f.data}) return { "name": scans[0].package_name, @@ -107,14 +108,5 @@ async def get_package( } for s in scans ], - "findings": [ - { - "id": f["id"], - "rule": f.get("rule"), - "severity": f.get("severity"), - "message": f.get("message"), - "location": f.get("location"), - } - for f in all_findings - ], + "findings": all_findings, } diff --git a/guarddog_nexus/api/scans.py b/guarddog_nexus/api/scans.py index c036a69..d43e680 100644 --- a/guarddog_nexus/api/scans.py +++ b/guarddog_nexus/api/scans.py @@ -1,7 +1,7 @@ """REST API for scans.""" from fastapi import APIRouter, Depends, Query -from sqlalchemy import func, select +from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -63,9 +63,12 @@ async def scan_stats(session: AsyncSession = Depends(get_session)): top_rules = ( await session.execute( - select(Finding.rule, func.count(Finding.id).label("cnt")) - .group_by(Finding.rule) - .order_by(func.count(Finding.id).desc()) + select( + func.json_extract(Finding.data, "$.rule").label("rule"), + func.count(Finding.id).label("cnt"), + ) + .group_by(text("rule")) + .order_by(text("cnt DESC")) .limit(10) ) ).all() @@ -103,14 +106,5 @@ async def get_scan(scan_id: int, session: AsyncSession = Depends(get_session)): "started_at": scan.started_at.isoformat() if scan.started_at else None, "finished_at": scan.finished_at.isoformat() if scan.finished_at else None, "error_message": scan.error_message, - "findings": [ - { - "id": f.id, - "rule": f.rule, - "severity": f.severity, - "message": f.message, - "location": f.location, - } - for f in scan.findings - ], + "findings": [{"id": f.id, **f.data} for f in scan.findings], } diff --git a/guarddog_nexus/harvester.py b/guarddog_nexus/harvester.py index b9c7f23..8fbba9d 100644 --- a/guarddog_nexus/harvester.py +++ b/guarddog_nexus/harvester.py @@ -83,14 +83,7 @@ async def harvest( findings_list = result.get("findings", []) for fdata in findings_list: - finding = Finding( - scan_id=scan.id, - rule=fdata["rule"], - severity=fdata["severity"], - message=fdata["message"], - location=fdata.get("location"), - ) - session.add(finding) + session.add(Finding(scan_id=scan.id, data=fdata)) scan.total_findings = len(findings_list) scan.flagged = len(findings_list) > 0 diff --git a/guarddog_nexus/models.py b/guarddog_nexus/models.py index 644cad4..b785103 100644 --- a/guarddog_nexus/models.py +++ b/guarddog_nexus/models.py @@ -3,7 +3,17 @@ import datetime from enum import Enum -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from guarddog_nexus.database import Base @@ -50,10 +60,7 @@ class Finding(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) scan_id: Mapped[int] = mapped_column(Integer, ForeignKey("scans.id"), nullable=False) - rule: Mapped[str] = mapped_column(String(255), nullable=False) - severity: Mapped[str] = mapped_column(String(50), nullable=False) - message: Mapped[str] = mapped_column(Text, nullable=False) - location: Mapped[str | None] = mapped_column(String(512), nullable=True) + data: Mapped[dict] = mapped_column(JSON, nullable=False) created_at: Mapped[datetime.datetime] = mapped_column( DateTime, nullable=False, default=func.now() ) diff --git a/guarddog_nexus/scanner.py b/guarddog_nexus/scanner.py index 991db92..0389041 100644 --- a/guarddog_nexus/scanner.py +++ b/guarddog_nexus/scanner.py @@ -77,6 +77,7 @@ def _normalize_output(data: dict) -> dict: "severity": "WARNING", "message": value, "location": "", + "code": "", } ) elif isinstance(value, list): @@ -88,6 +89,7 @@ def _normalize_output(data: dict) -> dict: "severity": item.get("severity", "WARNING"), "message": item.get("message", ""), "location": item.get("location", ""), + "code": item.get("code", ""), } ) elif isinstance(value, dict) and not value: diff --git a/guarddog_nexus/web/routes.py b/guarddog_nexus/web/routes.py index e55f642..364522c 100644 --- a/guarddog_nexus/web/routes.py +++ b/guarddog_nexus/web/routes.py @@ -4,7 +4,7 @@ import datetime from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse -from sqlalchemy import Integer, cast, func, select +from sqlalchemy import Integer, cast, func, select, text from sqlalchemy.ext.asyncio import AsyncSession from guarddog_nexus.database import get_session @@ -53,10 +53,14 @@ async def _dashboard_data(session: AsyncSession) -> dict: total_findings = await session.scalar(select(func.count(Finding.id))) warnings_count = await session.scalar( - select(func.count(Finding.id)).where(Finding.severity == "WARNING") + select(func.count(Finding.id)).where( + func.json_extract(Finding.data, "$.severity") == "WARNING" + ) ) errors_count = await session.scalar( - select(func.count(Finding.id)).where(Finding.severity == "ERROR") + select(func.count(Finding.id)).where( + func.json_extract(Finding.data, "$.severity") == "ERROR" + ) ) latest_flagged = ( @@ -77,9 +81,12 @@ async def _dashboard_data(session: AsyncSession) -> dict: top_rules = ( await session.execute( - select(Finding.rule, func.count(Finding.id).label("cnt")) - .group_by(Finding.rule) - .order_by(func.count(Finding.id).desc()) + select( + func.json_extract(Finding.data, "$.rule").label("rule"), + func.count(Finding.id).label("cnt"), + ) + .group_by(text("rule")) + .order_by(text("cnt DESC")) .limit(10) ) ).all() diff --git a/guarddog_nexus/web/templates/package_detail.html b/guarddog_nexus/web/templates/package_detail.html index bfa8558..b3c68bc 100644 --- a/guarddog_nexus/web/templates/package_detail.html +++ b/guarddog_nexus/web/templates/package_detail.html @@ -28,6 +28,7 @@ {{ f.rule }} {% if f.location %} @ {{ f.location }}{% endif %}

{{ f.message }}

+ {% if f.code %}
{{ f.code }}
{% endif %} {% endfor %} {% else %} diff --git a/guarddog_nexus/web/templates/scan_detail.html b/guarddog_nexus/web/templates/scan_detail.html index eb06b27..c7c967d 100644 --- a/guarddog_nexus/web/templates/scan_detail.html +++ b/guarddog_nexus/web/templates/scan_detail.html @@ -16,12 +16,13 @@

Findings ({{ scan.findings|length }})

{% if scan.findings %} - {% for f in scan.findings|sort(attribute='severity', reverse=true) %} -
- [{{ f.severity }}] - {{ f.rule }} - {% if f.location %} @ {{ f.location }}{% endif %} -

{{ f.message }}

+ {% for f in scan.findings|sort(attribute='data.severity', reverse=true) %} +
+ [{{ f.data.severity }}] + {{ f.data.rule }} + {% if f.data.location %} @ {{ f.data.location }}{% endif %} +

{{ f.data.message }}

+ {% if f.data.code %}
{{ f.data.code }}
{% endif %}
{% endfor %} {% else %} diff --git a/tests/conftest.py b/tests/conftest.py index 945a0f0..db4371c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -147,18 +147,21 @@ def guarddog_normalized_flagged(): "severity": "WARNING", "message": "Package contains URL to suspicious domain", "location": "setup.py:15", + "code": "url = 'http://evil.com'", }, { "rule": "exec-base64", "severity": "WARNING", "message": "Base64-encoded code execution detected", "location": "core.py:42", + "code": "exec(base64.b64decode(...))", }, { "rule": "empty_information", "severity": "WARNING", "message": "Package description is empty", "location": "", + "code": "", }, ], "errors": [], diff --git a/tests/test_harvester.py b/tests/test_harvester.py index a437c7b..423abef 100644 --- a/tests/test_harvester.py +++ b/tests/test_harvester.py @@ -43,6 +43,12 @@ async def test_harvest_new_package(db_session, guarddog_normalized_flagged): .all() ) assert len(findings) == 3 + rules = {f.data["rule"] for f in findings} + assert "shady-links" in rules + # Check code is preserved + for f in findings: + if f.data["rule"] == "shady-links": + assert f.data["code"] == "url = 'http://evil.com'" @pytest.mark.asyncio