diff --git a/backend/alembic/versions/b021_add_phase_timing_fields.py b/backend/alembic/versions/b021_add_phase_timing_fields.py new file mode 100644 index 0000000..0f57105 --- /dev/null +++ b/backend/alembic/versions/b021_add_phase_timing_fields.py @@ -0,0 +1,32 @@ +"""add_phase_timing_fields + +Revision ID: b021phasetiming +Revises: b020jiraworklogs +Create Date: 2026-02-17 18:00:00.000000 + +Add red_started_at and blue_started_at columns to the tests table +so that automatic worklogs can record real elapsed time per phase. +""" + +from alembic import op + +revision = "b021phasetiming" +down_revision = "b020jiraworklogs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE tests + ADD COLUMN IF NOT EXISTS red_started_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS blue_started_at TIMESTAMP; + """) + + +def downgrade() -> None: + op.execute(""" + ALTER TABLE tests + DROP COLUMN IF EXISTS red_started_at, + DROP COLUMN IF EXISTS blue_started_at; + """) diff --git a/backend/app/models/test.py b/backend/app/models/test.py index b23c37a..e66fe2a 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -49,6 +49,10 @@ class Test(Base): blue_validation_status = Column(String, nullable=True) # pending / approved / rejected blue_validation_notes = Column(Text, nullable=True) + # ── Phase timing fields (for automatic Tempo worklogs) ────────── + red_started_at = Column(DateTime, nullable=True) + blue_started_at = Column(DateTime, nullable=True) + # ── Remediation fields ─────────────────────────────────────────── remediation_steps = Column(Text, nullable=True) remediation_status = Column(String, nullable=True) # pending / in_progress / completed / not_applicable diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index 40c6c04..b759a03 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -137,6 +137,10 @@ class TestOut(BaseModel): blue_validation_status: str | None = None blue_validation_notes: str | None = None + # Phase timing fields (for Tempo worklogs) + red_started_at: datetime | None = None + blue_started_at: datetime | None = None + # Remediation fields remediation_steps: str | None = None remediation_status: str | None = None diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index ca5ed23..41a6540 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -89,8 +89,26 @@ def auto_log_test_worklog( def _calculate_duration(test, activity_type: str) -> int: - """Estimate duration in seconds based on test timestamps and activity type.""" + """Calculate real duration in seconds from the phase timing fields. + + Uses the actual start/end timestamps recorded by the workflow buttons, + so the data cannot be falsified. + """ + from datetime import datetime + + now = datetime.utcnow() + + if activity_type == "red_team_execution" and test.red_started_at: + delta = now - test.red_started_at + return max(int(delta.total_seconds()), 1) + + if activity_type == "blue_team_evaluation" and test.blue_started_at: + delta = now - test.blue_started_at + return max(int(delta.total_seconds()), 1) + + # Fallback for legacy activity types if activity_type == "execution" and test.execution_date and test.created_at: delta = test.execution_date - test.created_at return max(int(delta.total_seconds()), 0) - return 3600 # default 1 hour if no timestamps available + + return 0 diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index ea0b07e..841fe02 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -113,12 +113,15 @@ def start_execution(db: Session, test: Test, user: User) -> Test: """Move from ``draft`` → ``red_executing``. Typically called by a **red_tech** when they begin the attack. + Starts the Red Team timer by recording ``red_started_at``. """ + now = datetime.utcnow() test = transition_state( db, test, TestState.red_executing, user, action_name="start_execution", ) - test.execution_date = datetime.utcnow() + test.execution_date = now + test.red_started_at = now db.commit() return test @@ -127,11 +130,29 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test: """Move from ``red_executing`` → ``blue_evaluating``. Called by **red_tech** once they have finished documenting the attack. + Stops the Red Team timer and creates an automatic worklog. + Starts the Blue Team timer by recording ``blue_started_at``. """ + now = datetime.utcnow() + test = transition_state( db, test, TestState.blue_evaluating, user, action_name="submit_red_evidence", ) + + # Create automatic worklog for Red Team phase + _create_phase_worklog( + db, + test=test, + user=user, + phase_started_at=test.red_started_at, + phase_ended_at=now, + activity_type="red_team_execution", + description=f"Red Team execution: {test.name}", + ) + + # Start Blue Team timer + test.blue_started_at = now db.commit() return test @@ -140,15 +161,83 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test: """Move from ``blue_evaluating`` → ``in_review``. Called by **blue_tech** once they have finished documenting detection. + Stops the Blue Team timer and creates an automatic worklog. """ + now = datetime.utcnow() + test = transition_state( db, test, TestState.in_review, user, action_name="submit_blue_evidence", ) + + # Create automatic worklog for Blue Team phase + _create_phase_worklog( + db, + test=test, + user=user, + phase_started_at=test.blue_started_at, + phase_ended_at=now, + activity_type="blue_team_evaluation", + description=f"Blue Team evaluation: {test.name}", + ) + db.commit() return test +def _create_phase_worklog( + db: Session, + *, + test: Test, + user: User, + phase_started_at: datetime | None, + phase_ended_at: datetime, + activity_type: str, + description: str, +) -> None: + """Create an automatic, integrity-hashed worklog for a completed phase. + + Also triggers Tempo sync if the test has a Jira link. + """ + if not phase_started_at: + logger.warning( + "No phase start timestamp for test %s (%s), skipping worklog", + test.id, activity_type, + ) + return + + duration_seconds = max(int((phase_ended_at - phase_started_at).total_seconds()), 1) + + try: + from app.services.worklog_service import create_worklog + + wl = create_worklog( + db, + entity_type="test", + entity_id=test.id, + user_id=user.id, + activity_type=activity_type, + started_at=phase_started_at, + ended_at=phase_ended_at, + duration_seconds=duration_seconds, + description=description, + ) + logger.info( + "Auto-worklog created for test %s: %s, %ds (worklog %s)", + test.id, activity_type, duration_seconds, wl.id, + ) + + # Sync to Tempo if enabled + try: + from app.services.tempo_service import auto_log_test_worklog + auto_log_test_worklog(db, test, user, activity_type) + except Exception as e: + logger.warning("Tempo sync failed for worklog: %s", e, exc_info=True) + + except Exception as e: + logger.error("Failed to create auto-worklog for test %s: %s", test.id, e, exc_info=True) + + def validate_as_red_lead( db: Session, test: Test, @@ -428,5 +517,9 @@ def reopen_test(db: Session, test: Test, user: User) -> Test: test.blue_validated_at = None test.blue_validation_notes = None + # Clear phase timing fields + test.red_started_at = None + test.blue_started_at = None + db.commit() return test diff --git a/frontend/src/components/AddTestToCampaignModal.tsx b/frontend/src/components/AddTestToCampaignModal.tsx new file mode 100644 index 0000000..2dc6df7 --- /dev/null +++ b/frontend/src/components/AddTestToCampaignModal.tsx @@ -0,0 +1,197 @@ +import { useState, useMemo } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + X, + Search, + Plus, + Loader2, + CheckCircle, + FlaskConical, +} from "lucide-react"; +import { getTests } from "../api/tests"; +import { addTestToCampaign } from "../api/campaigns"; +import type { Test, TestState } from "../types/models"; + +const stateBadge: Record = { + draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", + red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30", + blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30", + in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30", + validated: "bg-green-900/50 text-green-400 border-green-500/30", + rejected: "bg-red-900/50 text-red-400 border-red-500/30", +}; + +interface AddTestToCampaignModalProps { + campaignId: string; + existingTestIds: string[]; + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export default function AddTestToCampaignModal({ + campaignId, + existingTestIds, + open, + onClose, + onSuccess, +}: AddTestToCampaignModalProps) { + const queryClient = useQueryClient(); + const [searchText, setSearchText] = useState(""); + const [addedIds, setAddedIds] = useState>(new Set()); + + const { data: allTests, isLoading } = useQuery({ + queryKey: ["tests", "for-campaign-picker"], + queryFn: () => getTests({ limit: 200 }), + enabled: open, + }); + + const filteredTests = useMemo(() => { + if (!allTests) return []; + const alreadyIn = new Set([...existingTestIds, ...addedIds]); + let results = allTests.filter((t) => !alreadyIn.has(t.id)); + + if (searchText.trim()) { + const q = searchText.toLowerCase(); + results = results.filter( + (t) => + t.name.toLowerCase().includes(q) || + (t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) || + (t.technique_name && t.technique_name.toLowerCase().includes(q)) + ); + } + + return results; + }, [allTests, searchText, existingTestIds, addedIds]); + + const addMutation = useMutation({ + mutationFn: (testId: string) => + addTestToCampaign(campaignId, { test_id: testId }), + onSuccess: (_data, testId) => { + setAddedIds((prev) => new Set(prev).add(testId)); + queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); + onSuccess(); + }, + }); + + if (!open) return null; + + return ( +
+
+ {/* Header */} +
+

+ Add Tests to Campaign +

+ +
+ + {/* Search */} +
+
+ + setSearchText(e.target.value)} + placeholder="Search tests by name or technique..." + autoFocus + className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2.5 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + /> +
+
+ + {/* Test list */} +
+ {isLoading ? ( +
+ +
+ ) : filteredTests.length === 0 ? ( +
+ +

+ {searchText + ? "No tests match your search." + : "All available tests are already in this campaign."} +

+
+ ) : ( +
+ {filteredTests.map((test: Test) => ( +
+
+
+ + {test.name} + + + {test.state.replace(/_/g, " ")} + +
+
+ {test.technique_mitre_id && ( + + {test.technique_mitre_id} + + )} + {test.technique_name && ( + {test.technique_name} + )} + {test.platform && ( + {test.platform} + )} +
+
+ +
+ ))} +
+ )} +
+ + {/* Footer */} +
+
+ {addedIds.size > 0 && ( + + + {addedIds.size} test{addedIds.size !== 1 ? "s" : ""} added + + )} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/test-detail/LiveTimer.tsx b/frontend/src/components/test-detail/LiveTimer.tsx new file mode 100644 index 0000000..4ffd66c --- /dev/null +++ b/frontend/src/components/test-detail/LiveTimer.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from "react"; +import { Timer } from "lucide-react"; + +interface LiveTimerProps { + startedAt: string; + label: string; + variant: "red" | "blue"; +} + +/** + * Real-time elapsed timer that counts up from a given start timestamp. + * Shown while a Red/Blue Team phase is active so users can see + * exactly how long they've been working. This time is recorded + * as an automatic worklog when the phase ends. + */ +export default function LiveTimer({ startedAt, label, variant }: LiveTimerProps) { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const start = new Date(startedAt).getTime(); + + const tick = () => { + const now = Date.now(); + setElapsed(Math.max(0, Math.floor((now - start) / 1000))); + }; + + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [startedAt]); + + const hours = Math.floor(elapsed / 3600); + const minutes = Math.floor((elapsed % 3600) / 60); + const seconds = elapsed % 60; + + const pad = (n: number) => String(n).padStart(2, "0"); + + const colors = + variant === "red" + ? "border-orange-500/40 bg-orange-900/30 text-orange-300" + : "border-indigo-500/40 bg-indigo-900/30 text-indigo-300"; + + const dotColor = variant === "red" ? "bg-orange-400" : "bg-indigo-400"; + + return ( +
+
+ + +
+
+ + {label} + + + {pad(hours)}:{pad(minutes)}:{pad(seconds)} + +
+
+ ); +} diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index 055971f..d37c946 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -10,6 +10,7 @@ import { ShieldCheck, } from "lucide-react"; import type { Test, TestState, User } from "../../types/models"; +import LiveTimer from "./LiveTimer"; // ── Progress steps ───────────────────────────────────────────────── @@ -235,6 +236,30 @@ export default function TestDetailHeader({ ); }; + // ── Live timer ─────────────────────────────────────────────────── + + const renderLiveTimer = () => { + if (test.state === "red_executing" && test.red_started_at) { + return ( + + ); + } + if (test.state === "blue_evaluating" && test.blue_started_at) { + return ( + + ); + } + return null; + }; + // ── Render ─────────────────────────────────────────────────────── return ( @@ -263,7 +288,10 @@ export default function TestDetailHeader({ - {renderActions()} +
+ {renderLiveTimer()} + {renderActions()} +
{/* Progress bar */} diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index be6ec5c..ec5d52c 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -30,6 +30,7 @@ import { useAuth } from "../context/AuthContext"; import CampaignTimeline from "../components/CampaignTimeline"; import JiraLinkPanel from "../components/JiraLinkPanel"; import WorklogTimeline from "../components/WorklogTimeline"; +import AddTestToCampaignModal from "../components/AddTestToCampaignModal"; const statusColors: Record = { draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", @@ -61,6 +62,7 @@ export default function CampaignDetailPage() { const { user } = useAuth(); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + const [showAddTestModal, setShowAddTestModal] = useState(false); const showToast = (message: string, type: "success" | "error") => { setToast({ message, type }); @@ -500,7 +502,7 @@ export default function CampaignDetailPage() { {canManage && campaign.status === "draft" && (