feat(tests): add Blue detect_procedure field mapped from template suggestion

Blue Team gets a detect_procedure field on Test (what they actually
did to detect the attack), Blue's counterpart to Red's procedure_text.
It's seeded from a new detect_suggested_procedure field on
TestTemplate at test-creation time, so a junior who later reuses the
same template starts with prior guidance instead of a blank field.

Also adds the procedure_suggestions review table and Test.source_template_id,
laying the groundwork for suggesting template improvements from filled-in
procedure fields (reviewed and approved by a lead, never auto-written).
detect_procedure is archived (not cleared) on Blue reopen, matching
blue_summary, and now appears in the Jira round-archived and blue_review
comments alongside the existing detection/containment fields.
This commit is contained in:
kitos
2026-07-14 14:58:29 +02:00
parent fd94e55799
commit 8bdbe48fbe
11 changed files with 127 additions and 1 deletions
+2
View File
@@ -45,6 +45,7 @@ from app.models.technique import Technique
from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.test_template import TestTemplate
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.user import User
# Assign __all__ = [
@@ -88,4 +89,5 @@ __all__ = [
"AlertRule",
"AlertInstance",
"TestRoundHistory",
"ProcedureSuggestion",
]
@@ -0,0 +1,41 @@
"""SQLAlchemy model for procedure-improvement suggestions.
When an operator submits a round with a filled-in procedure field
(``procedure_text`` for Red, ``detect_procedure`` for Blue), the command(s)
in it are extracted heuristically and proposed as an update to the
originating template's suggested-procedure field. Nothing is written to
the template automatically — a lead reviews and approves or rejects each
suggestion, so a junior who later picks up the same template only ever
sees vetted guidance.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class ProcedureSuggestion(Base):
"""A proposed update to a TestTemplate's suggested procedure, pending lead review."""
__tablename__ = "procedure_suggestions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=False)
team = Column(String(10), nullable=False) # "red" or "blue"
suggested_text = Column(Text, nullable=False)
source_test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reviewed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
template = relationship("TestTemplate", foreign_keys=[template_id])
source_test = relationship("Test", foreign_keys=[source_test_id])
submitter = relationship("User", foreign_keys=[submitted_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+9
View File
@@ -59,6 +59,12 @@ class Test(Base):
execution_date = Column(DateTime, nullable=True)
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# The template this test was instantiated from, if any (null for
# standalone/manually-created/RT-imported tests). Lets a procedure
# suggestion know exactly which template to propose improving —
# matching purely by technique would be ambiguous when a technique has
# multiple templates.
source_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
result = Column(Enum(TestResult, name="testresult"), nullable=True)
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
@@ -83,6 +89,9 @@ class Test(Base):
# ── Blue Team fields ────────────────────────────────────────────
blue_summary = Column(Text, nullable=True)
# What Blue actually did to detect the attack — Blue's counterpart to
# procedure_text. Free text; may be parsed for a procedure suggestion.
detect_procedure = Column(Text, nullable=True)
# Assign 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)
+1
View File
@@ -50,6 +50,7 @@ class TestRoundHistory(Base):
detection_time = Column(DateTime, nullable=True)
containment_time = Column(DateTime, nullable=True)
blue_summary = Column(Text, nullable=True)
detect_procedure = Column(Text, nullable=True)
# Why the round was closed
review_notes = Column(Text, nullable=True)
+5
View File
@@ -43,6 +43,11 @@ class TestTemplate(Base):
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
# Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
expected_detection = Column(Text, nullable=True) # What blue team should detect
# Suggested detection procedure — Blue's counterpart to attack_procedure.
# Only ever filled in via an approved procedure suggestion or a lead
# editing the template directly; external syncs never touch it (those
# only insert brand-new rows, never update existing ones).
detect_suggested_procedure = Column(Text, nullable=True)
# Assign platform = Column(String, nullable=True) # windows / linux...
platform = Column(String, nullable=True) # windows / linux / macos
# Assign tool_suggested = Column(String, nullable=True)