diff --git a/TODO.md b/TODO.md index b1e090d..8eda2a7 100644 --- a/TODO.md +++ b/TODO.md @@ -70,7 +70,7 @@ have no working implementation in the codebase yet. - [x] Forensic report parsing - [ ] Failure sample analysis - [x] PII redaction options -- [ ] Detailed authentication failure views +- [x] Detailed authentication failure views ### User Authentication & Multi-User Support - **Documented in**: README.md ("Built-in authentication via FastAPI Users"), diff --git a/backend/app/api/api_v1/endpoints/forensics.py b/backend/app/api/api_v1/endpoints/forensics.py index 38691e8..9b768e0 100644 --- a/backend/app/api/api_v1/endpoints/forensics.py +++ b/backend/app/api/api_v1/endpoints/forensics.py @@ -129,6 +129,9 @@ async def upload_forensic_report( @router.get("", response_model=ForensicListResponse) async def list_forensic_reports( domain: Optional[str] = Query(default=None), + source_ip: Optional[str] = Query(default=None), + auth_failure: Optional[str] = Query(default=None), + delivery_result: Optional[str] = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), @@ -141,6 +144,12 @@ async def list_forensic_reports( query = query.outerjoin(Domain).filter( (Domain.name == normalized) | (ForensicReport.reported_domain == normalized) ) + if source_ip: + query = query.filter(ForensicReport.source_ip == source_ip.strip()) + if auth_failure: + query = query.filter(ForensicReport.auth_failure == auth_failure.strip().lower()) + if delivery_result: + query = query.filter(ForensicReport.delivery_result == delivery_result.strip().lower()) total = query.count() rows = ( diff --git a/backend/app/main.py b/backend/app/main.py index abe8326..4865236 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -551,6 +551,22 @@ async def report_detail(request: Request, report_id: str): return templates.TemplateResponse(request, "report_detail.html", {"report_id": report_id}) +@app.get("/forensics", response_class=HTMLResponse) +async def forensic_reports(request: Request): + """View DMARC forensic authentication failure reports.""" + return templates.TemplateResponse(request, "forensic_reports.html") + + +@app.get("/forensics/{report_id}", response_class=HTMLResponse) +async def forensic_report_detail(request: Request, report_id: int): + """View detailed information for a specific forensic report.""" + return templates.TemplateResponse( + request, + "forensic_report_detail.html", + {"report_id": report_id}, + ) + + @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): return templates.TemplateResponse(request, "settings.html") diff --git a/backend/app/templates/forensic_report_detail.html b/backend/app/templates/forensic_report_detail.html new file mode 100644 index 0000000..117ccbe --- /dev/null +++ b/backend/app/templates/forensic_report_detail.html @@ -0,0 +1,166 @@ +{% extends "layouts/base.html" %} +{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %} + +{% block title %}DMARQ - Forensic Report Detail{% endblock %} + +{% block content %} +
+ + + + + + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/backend/app/templates/forensic_reports.html b/backend/app/templates/forensic_reports.html new file mode 100644 index 0000000..cba5097 --- /dev/null +++ b/backend/app/templates/forensic_reports.html @@ -0,0 +1,268 @@ +{% extends "layouts/base.html" %} +{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %} +{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %} + +{% block title %}DMARQ - Forensic Reports{% endblock %} + +{% block content %} +
+
+
+

Forensic Reports

+

Individual DMARC authentication failure reports

+
+ Privacy Settings +
+ +
+ {% call card() %} + {% call card_header() %}{% call card_title() %}Incidents{% endcall %}{% endcall %} + {% call card_content() %} +
+ {% endcall %} + {% endcall %} + {% call card() %} + {% call card_header() %}{% call card_title() %}DKIM Failures{% endcall %}{% endcall %} + {% call card_content() %} +
+ {% endcall %} + {% endcall %} + {% call card() %} + {% call card_header() %}{% call card_title() %}SPF Failures{% endcall %}{% endcall %} + {% call card_content() %} +
+ {% endcall %} + {% endcall %} + {% call card() %} + {% call card_header() %}{% call card_title() %}Rejected{% endcall %}{% endcall %} + {% call card_content() %} +
+ {% endcall %} + {% endcall %} +
+ +
+ {% call card() %} + {% call card_header() %} + {% call card_title() %}Filters{% endcall %} + {% endcall %} + {% call card_content() %} +
+ + + + +
+ {% endcall %} + {% endcall %} + + {% call card() %} + {% call card_header() %} + {% call card_title() %}Import Sample{% endcall %} + {% call card_description() %}Upload a forensic .eml or .txt file{% endcall %} + {% endcall %} + {% call card_content() %} +
+ + +

+
+ {% endcall %} + {% endcall %} +
+ + {% call card() %} + {% call card_header() %} +
+
+ {% call card_title() %}Authentication Failures{% endcall %} + {% call card_description() %} + Showing of reports + {% endcall %} +
+ +
+ {% endcall %} + {% call card_content() %} + + + + {% endcall %} + {% endcall %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/backend/app/templates/layouts/base.html b/backend/app/templates/layouts/base.html index e699513..4d6ae64 100644 --- a/backend/app/templates/layouts/base.html +++ b/backend/app/templates/layouts/base.html @@ -38,6 +38,7 @@
  • Dashboard
  • Domains
  • Reports
  • +
  • Forensics
  • Upload
  • Mail Sources
  • Health
  • diff --git a/backend/app/tests/test_forensics_api.py b/backend/app/tests/test_forensics_api.py index db14178..dc06592 100644 --- a/backend/app/tests/test_forensics_api.py +++ b/backend/app/tests/test_forensics_api.py @@ -1,5 +1,6 @@ import pytest from fastapi import HTTPException +from fastapi.testclient import TestClient from sqlalchemy.exc import IntegrityError from app.api.api_v1.endpoints import forensics as forensics_endpoint @@ -63,6 +64,32 @@ def test_list_and_detail_forensic_reports(authed_client): assert detail_response.json()["reported_domain"] == "example.com" +def test_list_forensic_reports_filters_failure_fields(authed_client, db_session): + first = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL) + second = dict(first) + second.update( + { + "report_id": "ruf-spf-filter-test", + "reported_domain": "example.net", + "source_ip": "198.51.100.77", + "auth_failure": "spf", + "delivery_result": "none", + } + ) + save_forensic_report(db_session, first) + save_forensic_report(db_session, second) + db_session.commit() + + response = authed_client.get( + "/api/v1/forensics?auth_failure=spf&delivery_result=none&source_ip=198.51.100.77" + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["reports"][0]["report_id"] == "ruf-spf-filter-test" + + def test_forensic_api_applies_configured_redaction_policy(authed_client, db_session): authed_client.post( "/api/v1/forensics/upload", @@ -175,6 +202,22 @@ def test_forensic_detail_returns_404(authed_client): assert response.status_code == 404 +def test_forensic_html_pages_render(): + from app.core.logto import SESSION_COOKIE, create_session_token # noqa: PLC0415 + from app.main import app as main_app # noqa: PLC0415 + + cookies = {SESSION_COOKIE: create_session_token(user_id=1)} + with TestClient(main_app) as c: + list_response = c.get("/forensics", cookies=cookies) + detail_response = c.get("/forensics/123", cookies=cookies) + + assert list_response.status_code == 200 + assert "Forensic Reports" in list_response.text + assert "Authentication Failures" in list_response.text + assert detail_response.status_code == 200 + assert "Forensic Investigation" in detail_response.text + + def test_save_forensic_report_duplicate_and_invalid_domain_paths(db_session): parsed = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL) parsed["feedback_headers"] = {"identity_alignment": "dkim"} diff --git a/docs/milestones.md b/docs/milestones.md index 662edc2..42d078d 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -174,9 +174,9 @@ Delivered: - Configure forensic report redaction for balanced, domain-only, and strict views. - Keep forensic reports out of aggregate report statistics and ReportStore rollups. - Expose authenticated forensic upload/list/detail APIs. +- Provide dedicated forensic report list/detail views for authentication failure investigation. Planned: -- Add a dedicated forensic report view. - Add richer failure investigation workflows. Exit criteria: