From 63da22b77e507e8864aa6340eb3ae14bb7bc79ce Mon Sep 17 00:00:00 2001 From: kitos Date: Tue, 19 May 2026 12:05:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(qa):=205=20bug=20fixes=20=E2=80=94=20audit?= =?UTF-8?q?=20dates,=20CSP,=20template=20modal,=20MITRE=20sync=20timeout,?= =?UTF-8?q?=20data=20source=20auto-sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit_service: set timestamp=datetime.now(utc) explicitly so DB never stores NULL - AuditLogPage: formatDate handles null/undefined timestamps (was showing Jan 1 1970) - nginx.conf: add CSP script-src hash for inline script (sha256-31OgE8E9...) - system.py: MITRE sync now runs in BackgroundTasks — returns immediately, no more 120s timeout - mitre_sync_job.py: add _run_data_sources_sync job (every 6h) that checks sync_frequency and auto-syncs overdue enabled data sources - SystemPage: MITRE sync result shows "started" vs "complete" message - test-templates.ts: add updateTemplate() API function - SystemPage: template name cell is now clickable — opens TemplateDetailModal with full edit form (name, description, procedure, detection, platform, severity, tool) and Save / Activate / Deactivate / Close buttons Co-Authored-By: Claude Sonnet 4.6 --- backend/app/jobs/mitre_sync_job.py | 66 ++++++- backend/app/routers/system.py | 34 +++- backend/app/services/audit_service.py | 1 + frontend/nginx.conf | 2 +- frontend/src/api/system.ts | 5 +- frontend/src/api/test-templates.ts | 11 ++ frontend/src/pages/AuditLogPage.tsx | 7 +- frontend/src/pages/SystemPage.tsx | 259 ++++++++++++++++++++++++-- 8 files changed, 355 insertions(+), 30 deletions(-) diff --git a/backend/app/jobs/mitre_sync_job.py b/backend/app/jobs/mitre_sync_job.py index 45a9680..d3f96ab 100644 --- a/backend/app/jobs/mitre_sync_job.py +++ b/backend/app/jobs/mitre_sync_job.py @@ -11,6 +11,7 @@ sessions. """ import logging +from datetime import datetime, timedelta, timezone from apscheduler.schedulers.background import BackgroundScheduler @@ -124,6 +125,61 @@ def _run_osint_enrichment() -> None: db.close() +_FREQUENCY_INTERVALS: dict[str, timedelta] = { + "daily": timedelta(days=1), + "weekly": timedelta(weeks=1), + "monthly": timedelta(days=30), +} + + +def _run_data_sources_sync() -> None: + """Check all enabled data sources and sync those that are overdue.""" + logger.info("Scheduled data sources sync check starting...") + db = SessionLocal() + try: + from app.models.data_source import DataSource + from app.services.data_source_service import sync_source + + now = datetime.now(timezone.utc) + sources = ( + db.query(DataSource) + .filter(DataSource.is_enabled == True) # noqa: E712 + .all() + ) + synced = 0 + for ds in sources: + freq = ds.sync_frequency + if not freq or freq == "manual": + continue + interval = _FREQUENCY_INTERVALS.get(freq) + if interval is None: + continue + last = ds.last_sync_at + if last is None: + # Never synced — run it now + overdue = True + else: + # Make last timezone-aware if needed + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + overdue = now - last >= interval + if overdue: + logger.info( + "Data source '%s' is overdue (freq=%s, last=%s) — syncing", + ds.name, freq, last, + ) + try: + sync_source(db, str(ds.id)) + synced += 1 + except Exception: + logger.exception("Failed to sync data source '%s'", ds.name) + logger.info("Data sources sync check finished — %d source(s) synced", synced) + except Exception: + logger.exception("Data sources sync check failed") + finally: + db.close() + + def _run_stale_detection() -> None: """Execute daily stale coverage detection inside its own DB session.""" logger.info("Scheduled stale coverage detection starting...") @@ -226,11 +282,19 @@ def start_scheduler() -> None: name="Data retention policies (daily)", replace_existing=True, ) + scheduler.add_job( + _run_data_sources_sync, + trigger="interval", + hours=6, + id="data_sources_sync", + name="Data sources auto-sync (every 6h)", + replace_existing=True, + ) scheduler.start() logger.info( "Background scheduler started — mitre_sync (24h), intel_scan (7d), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " "recurring_campaigns (daily), jira_sync (1h), " "osint_enrichment (weekly), stale_detection (daily), " - "retention_policies (daily)" + "retention_policies (daily), data_sources_sync (6h)" ) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index ac1e1df..96ee96d 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -7,10 +7,10 @@ scheduler health introspection. import logging -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, BackgroundTasks, Depends, Request from sqlalchemy.orm import Session -from app.database import get_db +from app.database import SessionLocal, get_db from app.dependencies.auth import require_role from app.models.user import User from app.services.mitre_sync_service import sync_mitre @@ -24,25 +24,39 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/system", tags=["system"]) +def _bg_mitre_sync() -> None: + """Run MITRE sync in a background task with its own DB session.""" + logger.info("Background MITRE sync task starting...") + db = SessionLocal() + try: + summary = sync_mitre(db) + logger.info("Background MITRE sync task finished — %s", summary) + except Exception: + logger.exception("Background MITRE sync task failed") + finally: + db.close() + + @router.post("/sync-mitre") @limiter.limit("2/hour") def trigger_mitre_sync( request: Request, - db: Session = Depends(get_db), + background_tasks: BackgroundTasks, current_user: User = Depends(require_role("admin")), ): - """Manually trigger a MITRE ATT&CK synchronisation. + """Manually trigger a MITRE ATT&CK synchronisation in the background. **Requires** the ``admin`` role. - Returns a JSON object with the sync summary including the count of - new and updated techniques. + Returns immediately — the sync runs asynchronously. Poll + ``/system/scheduler-status`` for progress, or check server logs. """ - summary = sync_mitre(db) + background_tasks.add_task(_bg_mitre_sync) return { - "message": "MITRE sync completed", - "new": summary["created"], - "updated": summary["updated"], + "message": "MITRE sync started in background", + "status": "started", + "new": 0, + "updated": 0, } diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index bf1c3a6..22cc8d0 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -58,6 +58,7 @@ def log_action( ip_address=ip or None, user_agent=ua or None, session_id=session_id, + timestamp=datetime.now(timezone.utc), ) db.add(entry) db.flush() diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 872dc2d..7f8b2f8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -14,7 +14,7 @@ server { # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # CSP: allow self + inline styles (React build) + data: URIs for fonts/images - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-31OgE8E9uFi947Hj0TYz0o9NSyrQOewgXrj1ZPfYDaY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; # Hide Nginx version server_tokens off; diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index b82ebdf..5f8237b 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -2,8 +2,9 @@ import client from "./client"; export interface SyncMitreResponse { message: string; - new: number; - updated: number; + status?: string; + new?: number; + updated?: number; } export interface IntelScanResponse { diff --git a/frontend/src/api/test-templates.ts b/frontend/src/api/test-templates.ts index e1ee5ba..6d5fea5 100644 --- a/frontend/src/api/test-templates.ts +++ b/frontend/src/api/test-templates.ts @@ -98,6 +98,17 @@ export async function createTemplate( return data; } +// ── Update (admin) ──────────────────────────────────────────────── + +/** Update fields of an existing test template. Admin/lead only. */ +export async function updateTemplate( + id: string, + payload: Partial, +): Promise { + const { data } = await client.patch(`/test-templates/${id}`, payload); + return data; +} + // ── Stats (admin) ────────────────────────────────────────────────── export interface TemplateStats { diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx index c01ab7d..4aa9766 100644 --- a/frontend/src/pages/AuditLogPage.tsx +++ b/frontend/src/pages/AuditLogPage.tsx @@ -61,8 +61,11 @@ export default function AuditLogPage() { const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date; - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleString("en-US", { + const formatDate = (dateStr: string | null | undefined) => { + if (!dateStr) return "—"; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return "—"; + return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index 8e393af..567851f 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -18,6 +18,7 @@ import { ToggleRight, BarChart3, X, + Pencil, } from "lucide-react"; import { triggerMitreSync, @@ -30,6 +31,7 @@ import { getTemplateStats, getAllTemplates, createTemplate, + updateTemplate, toggleTemplateActive, bulkActivateTemplates, type TemplateStats, @@ -43,6 +45,7 @@ export default function SystemPage() { const [intelResult, setIntelResult] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); // ── Existing queries ───────────────────────────────────────────── const { @@ -119,6 +122,16 @@ export default function SystemPage() { }, }); + const updateTemplateMutation = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + updateTemplate(id, payload), + onSuccess: (updated) => { + setSelectedTemplate(updated); + queryClient.invalidateQueries({ queryKey: ["templates-admin"] }); + queryClient.invalidateQueries({ queryKey: ["test-templates"] }); + }, + }); + const formatNextRun = (dateStr: string | null) => { if (!dateStr) return "Not scheduled"; const date = new Date(dateStr); @@ -168,18 +181,11 @@ export default function SystemPage() {
- Sync Complete -
-
-
- New techniques: - {syncResult.new} -
-
- Updated: - {syncResult.updated} -
+ + {syncResult.status === "started" ? "Sync Started" : "Sync Complete"} +
+

{syncResult.message}

)} @@ -474,9 +480,14 @@ export default function SystemPage() { className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors" > - - {tpl.name} - + @@ -691,6 +702,18 @@ export default function SystemPage() { + + {/* Template Detail Modal */} + {selectedTemplate && ( + setSelectedTemplate(null)} + onSave={(id, payload) => updateTemplateMutation.mutate({ id, payload })} + onToggleActive={(id) => toggleActiveMutation.mutate(id)} + isSaving={updateTemplateMutation.isPending} + isToggling={toggleActiveMutation.isPending} + /> + )} ); } @@ -901,3 +924,211 @@ function CreateTemplateForm({ ); } + +/* ── Template Detail / Edit Modal ────────────────────────────────── */ + +function TemplateDetailModal({ + template, + onClose, + onSave, + onToggleActive, + isSaving, + isToggling, +}: { + template: TestTemplate; + onClose: () => void; + onSave: (id: string, payload: Partial) => void; + onToggleActive: (id: string) => void; + isSaving: boolean; + isToggling: boolean; +}) { + const [form, setForm] = useState>({ + name: template.name, + description: template.description ?? "", + attack_procedure: template.attack_procedure ?? "", + expected_detection: template.expected_detection ?? "", + platform: template.platform ?? "", + tool_suggested: template.tool_suggested ?? "", + severity: template.severity ?? "", + mitre_technique_id: template.mitre_technique_id, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(template.id, form); + }; + + return ( +
+
+ {/* Header */} +
+
+

Edit Template

+

{template.mitre_technique_id}

+
+ +
+ + {/* Meta badges */} +
+ + {template.source.replace(/_/g, " ")} + + + {template.is_active ? "Active" : "Inactive"} + +
+ + {/* Form */} +
+
+
+ + setForm({ ...form, name: e.target.value })} + required + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none" + /> +
+
+ + setForm({ ...form, mitre_technique_id: e.target.value })} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none" + /> +
+
+ + +
+
+ + +
+
+ +
+ +