fix(qa): 5 bug fixes — audit dates, CSP, template modal, MITRE sync timeout, data source auto-sync
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-19 12:05:35 +02:00
parent fd476ce460
commit 63da22b77e
8 changed files with 355 additions and 30 deletions

View File

@@ -11,6 +11,7 @@ sessions.
""" """
import logging import logging
from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -124,6 +125,61 @@ def _run_osint_enrichment() -> None:
db.close() 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: def _run_stale_detection() -> None:
"""Execute daily stale coverage detection inside its own DB session.""" """Execute daily stale coverage detection inside its own DB session."""
logger.info("Scheduled stale coverage detection starting...") logger.info("Scheduled stale coverage detection starting...")
@@ -226,11 +282,19 @@ def start_scheduler() -> None:
name="Data retention policies (daily)", name="Data retention policies (daily)",
replace_existing=True, 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() scheduler.start()
logger.info( logger.info(
"Background scheduler started — mitre_sync (24h), intel_scan (7d), " "Background scheduler started — mitre_sync (24h), intel_scan (7d), "
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
"recurring_campaigns (daily), jira_sync (1h), " "recurring_campaigns (daily), jira_sync (1h), "
"osint_enrichment (weekly), stale_detection (daily), " "osint_enrichment (weekly), stale_detection (daily), "
"retention_policies (daily)" "retention_policies (daily), data_sources_sync (6h)"
) )

View File

@@ -7,10 +7,10 @@ scheduler health introspection.
import logging import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, BackgroundTasks, Depends, Request
from sqlalchemy.orm import Session 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.dependencies.auth import require_role
from app.models.user import User from app.models.user import User
from app.services.mitre_sync_service import sync_mitre from app.services.mitre_sync_service import sync_mitre
@@ -24,25 +24,39 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/system", tags=["system"]) 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") @router.post("/sync-mitre")
@limiter.limit("2/hour") @limiter.limit("2/hour")
def trigger_mitre_sync( def trigger_mitre_sync(
request: Request, request: Request,
db: Session = Depends(get_db), background_tasks: BackgroundTasks,
current_user: User = Depends(require_role("admin")), 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. **Requires** the ``admin`` role.
Returns a JSON object with the sync summary including the count of Returns immediately — the sync runs asynchronously. Poll
new and updated techniques. ``/system/scheduler-status`` for progress, or check server logs.
""" """
summary = sync_mitre(db) background_tasks.add_task(_bg_mitre_sync)
return { return {
"message": "MITRE sync completed", "message": "MITRE sync started in background",
"new": summary["created"], "status": "started",
"updated": summary["updated"], "new": 0,
"updated": 0,
} }

View File

@@ -58,6 +58,7 @@ def log_action(
ip_address=ip or None, ip_address=ip or None,
user_agent=ua or None, user_agent=ua or None,
session_id=session_id, session_id=session_id,
timestamp=datetime.now(timezone.utc),
) )
db.add(entry) db.add(entry)
db.flush() db.flush()

View File

@@ -14,7 +14,7 @@ server {
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# CSP: allow self + inline styles (React build) + data: URIs for fonts/images # 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 # Hide Nginx version
server_tokens off; server_tokens off;

View File

@@ -2,8 +2,9 @@ import client from "./client";
export interface SyncMitreResponse { export interface SyncMitreResponse {
message: string; message: string;
new: number; status?: string;
updated: number; new?: number;
updated?: number;
} }
export interface IntelScanResponse { export interface IntelScanResponse {

View File

@@ -98,6 +98,17 @@ export async function createTemplate(
return data; return data;
} }
// ── Update (admin) ────────────────────────────────────────────────
/** Update fields of an existing test template. Admin/lead only. */
export async function updateTemplate(
id: string,
payload: Partial<CreateTemplatePayload>,
): Promise<TestTemplate> {
const { data } = await client.patch<TestTemplate>(`/test-templates/${id}`, payload);
return data;
}
// ── Stats (admin) ────────────────────────────────────────────────── // ── Stats (admin) ──────────────────────────────────────────────────
export interface TemplateStats { export interface TemplateStats {

View File

@@ -61,8 +61,11 @@ export default function AuditLogPage() {
const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date; const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date;
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string | null | undefined) => {
return new Date(dateStr).toLocaleString("en-US", { if (!dateStr) return "—";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",

View File

@@ -18,6 +18,7 @@ import {
ToggleRight, ToggleRight,
BarChart3, BarChart3,
X, X,
Pencil,
} from "lucide-react"; } from "lucide-react";
import { import {
triggerMitreSync, triggerMitreSync,
@@ -30,6 +31,7 @@ import {
getTemplateStats, getTemplateStats,
getAllTemplates, getAllTemplates,
createTemplate, createTemplate,
updateTemplate,
toggleTemplateActive, toggleTemplateActive,
bulkActivateTemplates, bulkActivateTemplates,
type TemplateStats, type TemplateStats,
@@ -43,6 +45,7 @@ export default function SystemPage() {
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null); const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null); const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<TestTemplate | null>(null);
// ── Existing queries ───────────────────────────────────────────── // ── Existing queries ─────────────────────────────────────────────
const { const {
@@ -119,6 +122,16 @@ export default function SystemPage() {
}, },
}); });
const updateTemplateMutation = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<CreateTemplatePayload> }) =>
updateTemplate(id, payload),
onSuccess: (updated) => {
setSelectedTemplate(updated);
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
},
});
const formatNextRun = (dateStr: string | null) => { const formatNextRun = (dateStr: string | null) => {
if (!dateStr) return "Not scheduled"; if (!dateStr) return "Not scheduled";
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -168,18 +181,11 @@ export default function SystemPage() {
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3"> <div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-400" /> <CheckCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">Sync Complete</span> <span className="text-sm font-medium text-green-400">
</div> {syncResult.status === "started" ? "Sync Started" : "Sync Complete"}
<div className="mt-2 grid grid-cols-2 gap-2 text-sm"> </span>
<div>
<span className="text-gray-400">New techniques:</span>
<span className="ml-2 font-medium text-white">{syncResult.new}</span>
</div>
<div>
<span className="text-gray-400">Updated:</span>
<span className="ml-2 font-medium text-white">{syncResult.updated}</span>
</div>
</div> </div>
<p className="mt-1 text-sm text-gray-400">{syncResult.message}</p>
</div> </div>
)} )}
@@ -474,9 +480,14 @@ export default function SystemPage() {
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors" className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
> >
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<span className="font-medium text-gray-200 truncate block max-w-[200px]"> <button
{tpl.name} onClick={() => setSelectedTemplate(tpl)}
</span> className="flex items-center gap-1.5 text-left font-medium text-cyan-400 hover:text-cyan-300 truncate max-w-[200px] transition-colors"
title="Click to view/edit"
>
<Pencil className="h-3 w-3 shrink-0 opacity-60" />
<span className="truncate">{tpl.name}</span>
</button>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className="font-mono text-xs text-cyan-400"> <span className="font-mono text-xs text-cyan-400">
@@ -691,6 +702,18 @@ export default function SystemPage() {
</div> </div>
</dl> </dl>
</div> </div>
{/* Template Detail Modal */}
{selectedTemplate && (
<TemplateDetailModal
template={selectedTemplate}
onClose={() => setSelectedTemplate(null)}
onSave={(id, payload) => updateTemplateMutation.mutate({ id, payload })}
onToggleActive={(id) => toggleActiveMutation.mutate(id)}
isSaving={updateTemplateMutation.isPending}
isToggling={toggleActiveMutation.isPending}
/>
)}
</div> </div>
); );
} }
@@ -901,3 +924,211 @@ function CreateTemplateForm({
</div> </div>
); );
} }
/* ── Template Detail / Edit Modal ────────────────────────────────── */
function TemplateDetailModal({
template,
onClose,
onSave,
onToggleActive,
isSaving,
isToggling,
}: {
template: TestTemplate;
onClose: () => void;
onSave: (id: string, payload: Partial<CreateTemplatePayload>) => void;
onToggleActive: (id: string) => void;
isSaving: boolean;
isToggling: boolean;
}) {
const [form, setForm] = useState<Partial<CreateTemplatePayload>>({
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-white">Edit Template</h2>
<p className="mt-0.5 text-xs text-gray-400 font-mono">{template.mitre_technique_id}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X className="h-5 w-5" />
</button>
</div>
{/* Meta badges */}
<div className="flex flex-wrap gap-2 px-6 py-3 border-b border-gray-800">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
template.source === "atomic_red_team"
? "bg-red-900/50 text-red-400 border-red-500/30"
: template.source === "mitre"
? "bg-blue-900/50 text-blue-400 border-blue-500/30"
: "bg-gray-800/50 text-gray-400 border-gray-600/30"
}`}
>
{template.source.replace(/_/g, " ")}
</span>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
template.is_active
? "bg-green-900/50 text-green-400 border-green-500/30"
: "bg-gray-800/50 text-gray-500 border-gray-600/30"
}`}
>
{template.is_active ? "Active" : "Inactive"}
</span>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Template Name</label>
<input
type="text"
value={form.name ?? ""}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">MITRE Technique ID</label>
<input
type="text"
value={form.mitre_technique_id ?? ""}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Platform</label>
<select
value={form.platform ?? ""}
onChange={(e) => setForm({ ...form, platform: e.target.value || undefined })}
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"
>
<option value="">None</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
<option value="cloud">Cloud</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Severity</label>
<select
value={form.severity ?? ""}
onChange={(e) => setForm({ ...form, severity: e.target.value || undefined })}
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"
>
<option value="">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Description</label>
<textarea
value={form.description ?? ""}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Attack Procedure</label>
<textarea
value={form.attack_procedure ?? ""}
onChange={(e) => setForm({ ...form, attack_procedure: e.target.value })}
rows={3}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Expected Detection</label>
<textarea
value={form.expected_detection ?? ""}
onChange={(e) => setForm({ ...form, expected_detection: e.target.value })}
rows={2}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Suggested Tool</label>
<input
type="text"
value={form.tool_suggested ?? ""}
onChange={(e) => setForm({ ...form, tool_suggested: 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"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3 pt-2 border-t border-gray-800">
<button
type="submit"
disabled={isSaving}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
{isSaving ? "Saving..." : "Save Changes"}
</button>
<button
type="button"
onClick={() => onToggleActive(template.id)}
disabled={isToggling}
className={`flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
template.is_active
? "border-red-500/30 bg-red-900/20 text-red-400 hover:bg-red-900/40"
: "border-green-500/30 bg-green-900/20 text-green-400 hover:bg-green-900/40"
}`}
>
{isToggling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : template.is_active ? (
<ToggleRight className="h-4 w-4" />
) : (
<ToggleLeft className="h-4 w-4" />
)}
{template.is_active ? "Deactivate" : "Activate"}
</button>
<button
type="button"
onClick={onClose}
className="ml-auto rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
>
Close
</button>
</div>
</form>
</div>
</div>
);
}