feat(tests): containment_result field on blue team evaluation
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

This commit is contained in:
kitos
2026-06-24 12:35:19 +02:00
parent 18695197e8
commit 2e45cf1be0
9 changed files with 114 additions and 4 deletions
@@ -0,0 +1,31 @@
"""Add containment_result to tests.
Revision ID: b052
Revises: b051
Create Date: 2026-06-24
"""
from alembic import op
import sqlalchemy as sa
revision = "b052"
down_revision = "b051"
branch_labels = None
depends_on = None
containment_enum = sa.Enum(
"contained", "partially_contained", "not_contained",
name="containmentresult",
)
def upgrade() -> None:
containment_enum.create(op.get_bind(), checkfirst=True)
op.add_column(
"tests",
sa.Column("containment_result", containment_enum, nullable=True),
)
def downgrade() -> None:
op.drop_column("tests", "containment_result")
containment_enum.drop(op.get_bind(), checkfirst=True)
+9
View File
@@ -68,6 +68,15 @@ class TestResult(str, enum.Enum):
partially_detected = "partially_detected" partially_detected = "partially_detected"
# Define class ContainmentResult
class ContainmentResult(str, enum.Enum):
"""Outcome of the blue team's containment effort."""
contained = "contained"
partially_contained = "partially_contained"
not_contained = "not_contained"
# Define class DataClassification # Define class DataClassification
class DataClassification(str, enum.Enum): class DataClassification(str, enum.Enum):
"""Data sensitivity classification levels for compliance and retention policies.""" """Data sensitivity classification levels for compliance and retention policies."""
+1
View File
@@ -7,6 +7,7 @@ working with ``from app.models.enums import ...``.
# Import # noqa: F401 from app.domain.enums # Import # noqa: F401 from app.domain.enums
from app.domain.enums import ( # noqa: F401 from app.domain.enums import ( # noqa: F401
ContainmentResult,
DataClassification, DataClassification,
TeamSide, TeamSide,
TechniqueStatus, TechniqueStatus,
+3 -2
View File
@@ -26,8 +26,8 @@ from sqlalchemy.orm import relationship
# Import Base from app.database # Import Base from app.database
from app.database import Base from app.database import Base
# Import TestResult, TestState from app.models.enums # Import ContainmentResult, TestResult, TestState from app.models.enums
from app.models.enums import TestResult, TestState from app.models.enums import ContainmentResult, TestResult, TestState
# Define class Test # Define class Test
@@ -83,6 +83,7 @@ class Test(Base):
blue_summary = Column(Text, nullable=True) blue_summary = Column(Text, nullable=True)
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True) # Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True) detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
# Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign blue_validated_at = Column(DateTime, nullable=True) # Assign blue_validated_at = Column(DateTime, nullable=True)
+3 -1
View File
@@ -9,7 +9,7 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, model_validator from pydantic import BaseModel, ConfigDict, model_validator
# Import DataClassification from app.domain.enums # Import DataClassification from app.domain.enums
from app.domain.enums import DataClassification from app.domain.enums import ContainmentResult, DataClassification
from app.models.enums import TestResult, TestState from app.models.enums import TestResult, TestState
from app.schemas.evidence import EvidenceOut from app.schemas.evidence import EvidenceOut
@@ -92,6 +92,7 @@ class TestBlueUpdate(BaseModel):
# Assign detection_result = None # Assign detection_result = None
detection_result: TestResult | None = None detection_result: TestResult | None = None
containment_result: ContainmentResult | None = None
# Assign blue_summary = None # Assign blue_summary = None
blue_summary: str | None = None blue_summary: str | None = None
@@ -207,6 +208,7 @@ class TestOut(BaseModel):
blue_summary: str | None = None blue_summary: str | None = None
# Assign detection_result = None # Assign detection_result = None
detection_result: TestResult | None = None detection_result: TestResult | None = None
containment_result: ContainmentResult | None = None
# Assign blue_validated_by = None # Assign blue_validated_by = None
blue_validated_by: uuid.UUID | None = None blue_validated_by: uuid.UUID | None = None
# Assign blue_validated_at = None # Assign blue_validated_at = None
+2
View File
@@ -2,6 +2,7 @@ import client from "./client";
import type { import type {
Test, Test,
TestResult, TestResult,
ContainmentResult,
TestState, TestState,
TestTimelineEntry, TestTimelineEntry,
} from "../types/models"; } from "../types/models";
@@ -37,6 +38,7 @@ export interface RedUpdatePayload {
export interface BlueUpdatePayload { export interface BlueUpdatePayload {
detection_result?: TestResult; detection_result?: TestResult;
containment_result?: ContainmentResult;
blue_summary?: string; blue_summary?: string;
} }
@@ -17,6 +17,7 @@ import {
import type { import type {
Test, Test,
TestResult, TestResult,
ContainmentResult,
TeamSide, TeamSide,
Evidence, Evidence,
TestTimelineEntry, TestTimelineEntry,
@@ -59,6 +60,14 @@ const DETECTION_RESULTS: { value: TestResult; label: string; color: string }[] =
}, },
]; ];
// ── Containment result options ─────────────────────────────────────
const CONTAINMENT_RESULTS: { value: ContainmentResult; label: string; color: string }[] = [
{ value: "contained", label: "Contained", color: "border-green-500 bg-green-500/10 text-green-400" },
{ value: "partially_contained", label: "Partially Contained", color: "border-yellow-500 bg-yellow-500/10 text-yellow-400" },
{ value: "not_contained", label: "Not Contained", color: "border-red-500 bg-red-500/10 text-red-400" },
];
// ── Props ────────────────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────────────────
interface TeamTabsProps { interface TeamTabsProps {
@@ -80,6 +89,7 @@ interface TeamTabsProps {
onBlueFieldChange: (field: string, value: string) => void; onBlueFieldChange: (field: string, value: string) => void;
blueDraft: { blueDraft: {
detection_result: TestResult | ""; detection_result: TestResult | "";
containment_result: ContainmentResult | "";
blue_summary: string; blue_summary: string;
}; };
@@ -339,6 +349,53 @@ export default function TeamTabs({
)} )}
</div> </div>
{/* Containment Result */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Containment Result
</label>
{canEditBlue ? (
<div className={`flex flex-wrap gap-2 ${blueDraft.detection_result === "not_detected" ? "opacity-40 pointer-events-none" : ""}`}>
{CONTAINMENT_RESULTS.map((opt) => (
<button
key={opt.value}
onClick={() => onBlueFieldChange("containment_result", opt.value)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
blueDraft.containment_result === opt.value
? opt.color
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
}`}
>
{opt.label}
</button>
))}
{blueDraft.detection_result === "not_detected" && (
<span className="self-center text-xs text-gray-500">N/A attack not detected</span>
)}
</div>
) : (
<p className="text-sm">
{test.containment_result ? (
<span
className={`inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium ${
test.containment_result === "contained"
? "border-green-500/30 bg-green-900/50 text-green-400"
: test.containment_result === "not_contained"
? "border-red-500/30 bg-red-900/50 text-red-400"
: "border-yellow-500/30 bg-yellow-900/50 text-yellow-400"
}`}
>
{test.containment_result.replace(/_/g, " ")}
</span>
) : test.detection_result === "not_detected" ? (
<span className="text-gray-500">N/A</span>
) : (
<span className="text-gray-500">Not evaluated yet</span>
)}
</p>
)}
</div>
{/* Blue Summary */} {/* Blue Summary */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-gray-300">
+5 -1
View File
@@ -24,7 +24,7 @@ import {
} from "../api/tests"; } from "../api/tests";
import { uploadEvidence, getEvidence } from "../api/evidence"; import { uploadEvidence, getEvidence } from "../api/evidence";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { TestResult, TeamSide, TestTimelineEntry } from "../types/models"; import type { TestResult, ContainmentResult, TeamSide, TestTimelineEntry } from "../types/models";
import TestDetailHeader from "../components/test-detail/TestDetailHeader"; import TestDetailHeader from "../components/test-detail/TestDetailHeader";
import TeamTabs from "../components/test-detail/TeamTabs"; import TeamTabs from "../components/test-detail/TeamTabs";
@@ -62,9 +62,11 @@ export default function TestDetailPage() {
const [blueDraft, setBlueDraft] = useState<{ const [blueDraft, setBlueDraft] = useState<{
detection_result: TestResult | ""; detection_result: TestResult | "";
containment_result: ContainmentResult | "";
blue_summary: string; blue_summary: string;
}>({ }>({
detection_result: "", detection_result: "",
containment_result: "",
blue_summary: "", blue_summary: "",
}); });
@@ -109,6 +111,7 @@ export default function TestDetailPage() {
}); });
setBlueDraft({ setBlueDraft({
detection_result: test.detection_result || "", detection_result: test.detection_result || "",
containment_result: test.containment_result || "",
blue_summary: test.blue_summary || "", blue_summary: test.blue_summary || "",
}); });
} }
@@ -161,6 +164,7 @@ export default function TestDetailPage() {
mutationFn: () => mutationFn: () =>
updateTestBlue(testId!, { updateTestBlue(testId!, {
detection_result: (blueDraft.detection_result as TestResult) || undefined, detection_result: (blueDraft.detection_result as TestResult) || undefined,
containment_result: (blueDraft.containment_result as ContainmentResult) || undefined,
blue_summary: blueDraft.blue_summary || undefined, blue_summary: blueDraft.blue_summary || undefined,
}), }),
onSuccess: () => { onSuccess: () => {
+3
View File
@@ -56,6 +56,8 @@ export type TestState =
export type TestResult = "detected" | "not_detected" | "partially_detected"; export type TestResult = "detected" | "not_detected" | "partially_detected";
export type ContainmentResult = "contained" | "partially_contained" | "not_contained";
export type ValidationStatus = "pending" | "approved" | "rejected"; export type ValidationStatus = "pending" | "approved" | "rejected";
export type TeamSide = "red" | "blue"; export type TeamSide = "red" | "blue";
@@ -92,6 +94,7 @@ export interface Test {
// Blue Team fields // Blue Team fields
blue_summary: string | null; blue_summary: string | null;
detection_result: TestResult | null; detection_result: TestResult | null;
containment_result: ContainmentResult | null;
blue_validated_by: string | null; blue_validated_by: string | null;
blue_validated_at: string | null; blue_validated_at: string | null;
blue_validation_status: ValidationStatus | null; blue_validation_status: ValidationStatus | null;