12a5484003
Approving a remove_test modification request deletes the underlying Test row, which was cascading through test_id's ON DELETE CASCADE and wiping out the request row itself before it could be read back. Changed to ON DELETE SET NULL so the audit record (justification, reviewer, decision) survives. Adds regression coverage plus double-approve/reject idempotency tests.
293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""Campaign and CampaignTest models.
|
|
|
|
Campaigns group multiple tests into a kill chain sequence,
|
|
enabling simulation of complete attack chains and APT emulations.
|
|
"""
|
|
|
|
# Import uuid
|
|
import uuid
|
|
|
|
# Import from sqlalchemy
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
func,
|
|
)
|
|
|
|
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
|
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
|
|
# Import relationship from sqlalchemy.orm
|
|
from sqlalchemy.orm import relationship
|
|
|
|
# Import Base from app.database
|
|
from app.database import Base
|
|
|
|
|
|
# Define class Campaign
|
|
class Campaign(Base):
|
|
"""A campaign groups multiple tests into a sequenced attack chain.
|
|
|
|
Types:
|
|
- custom: manually created campaign
|
|
- apt_emulation: generated from a threat actor profile
|
|
- kill_chain: structured around kill chain phases
|
|
- compliance: targeting specific compliance requirements
|
|
|
|
Status:
|
|
- draft: being configured
|
|
- active: tests are being executed
|
|
- completed: all tests done
|
|
- archived: historical record
|
|
"""
|
|
# Assign __tablename__ = "campaigns"
|
|
__tablename__ = "campaigns"
|
|
|
|
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
# Assign name = Column(String, nullable=False)
|
|
name = Column(String, nullable=False)
|
|
# Assign description = Column(Text, nullable=True)
|
|
description = Column(Text, nullable=True)
|
|
# Assign type = Column(String, nullable=False, default="custom") # custom, ap...
|
|
type = Column(String, nullable=False, default="custom") # custom, apt_emulation, kill_chain, compliance
|
|
# Assign threat_actor_id = Column(
|
|
threat_actor_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("threat_actors.id", ondelete="SET NULL"),
|
|
# Keyword argument: nullable
|
|
nullable=True,
|
|
)
|
|
# Assign status = Column(String, nullable=False, default="draft") # draft, activ...
|
|
status = Column(String, nullable=False, default="draft") # draft, active, completed, archived
|
|
# Assign created_by = Column(
|
|
created_by = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
# Keyword argument: nullable
|
|
nullable=True,
|
|
)
|
|
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
|
|
scheduled_at = Column(DateTime, nullable=True)
|
|
approved_by = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
approved_at = Column(DateTime, nullable=True)
|
|
rejection_reason = Column(Text, nullable=True)
|
|
# Assign completed_at = Column(DateTime, nullable=True)
|
|
completed_at = Column(DateTime, nullable=True)
|
|
# Assign target_platform = Column(String, nullable=True)
|
|
target_platform = Column(String, nullable=True)
|
|
# Assign tags = Column(JSONB, nullable=True, default=[])
|
|
tags = Column(JSONB, nullable=True, default=[])
|
|
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
|
data_classification = Column(String(20), nullable=False, server_default="internal")
|
|
|
|
# Recurring scheduling fields
|
|
is_recurring = Column(Boolean, default=False)
|
|
# Assign recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
|
|
recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
|
|
# Assign next_run_at = Column(DateTime, nullable=True)
|
|
next_run_at = Column(DateTime, nullable=True)
|
|
# Assign last_run_at = Column(DateTime, nullable=True)
|
|
last_run_at = Column(DateTime, nullable=True)
|
|
# Assign parent_campaign_id = Column(
|
|
parent_campaign_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
|
# Keyword argument: nullable
|
|
nullable=True,
|
|
)
|
|
|
|
# Relationships
|
|
threat_actor = relationship("ThreatActor")
|
|
# Assign creator = relationship("User", foreign_keys=[created_by])
|
|
creator = relationship("User", foreign_keys=[created_by])
|
|
# Assign campaign_tests = relationship(
|
|
campaign_tests = relationship(
|
|
# Literal argument value
|
|
"CampaignTest",
|
|
# Keyword argument: back_populates
|
|
back_populates="campaign",
|
|
# Keyword argument: cascade
|
|
cascade="all, delete-orphan",
|
|
# Keyword argument: order_by
|
|
order_by="CampaignTest.order_index",
|
|
)
|
|
# Assign parent_campaign = relationship(
|
|
parent_campaign = relationship(
|
|
# Literal argument value
|
|
"Campaign",
|
|
# Keyword argument: remote_side
|
|
remote_side="Campaign.id",
|
|
# Keyword argument: foreign_keys
|
|
foreign_keys=[parent_campaign_id],
|
|
)
|
|
# Assign child_campaigns = relationship(
|
|
child_campaigns = relationship(
|
|
# Literal argument value
|
|
"Campaign",
|
|
# Keyword argument: foreign_keys
|
|
foreign_keys=[parent_campaign_id],
|
|
# Keyword argument: back_populates
|
|
back_populates="parent_campaign",
|
|
)
|
|
|
|
# Assign __table_args__ = (
|
|
__table_args__ = (
|
|
Index('ix_campaigns_status', 'status'),
|
|
Index('ix_campaigns_type', 'type'),
|
|
Index('ix_campaigns_threat_actor', 'threat_actor_id'),
|
|
Index('ix_campaigns_created_by', 'created_by'),
|
|
Index('ix_campaigns_next_run', 'next_run_at'),
|
|
)
|
|
|
|
|
|
# Kill chain phases in order (for sorting and validation)
|
|
KILL_CHAIN_PHASES = [
|
|
# Literal argument value
|
|
"reconnaissance",
|
|
# Literal argument value
|
|
"resource_development",
|
|
# Literal argument value
|
|
"initial_access",
|
|
# Literal argument value
|
|
"execution",
|
|
# Literal argument value
|
|
"persistence",
|
|
# Literal argument value
|
|
"privilege_escalation",
|
|
# Literal argument value
|
|
"defense_evasion",
|
|
# Literal argument value
|
|
"credential_access",
|
|
# Literal argument value
|
|
"discovery",
|
|
# Literal argument value
|
|
"lateral_movement",
|
|
# Literal argument value
|
|
"collection",
|
|
# Literal argument value
|
|
"command_and_control",
|
|
# Literal argument value
|
|
"exfiltration",
|
|
# Literal argument value
|
|
"impact",
|
|
]
|
|
|
|
|
|
# Define class CampaignTest
|
|
class CampaignTest(Base):
|
|
"""A test within a campaign, with ordering and dependency information.
|
|
|
|
``depends_on`` creates a self-referential chain (A -> B -> C).
|
|
Circular dependencies are validated at the service layer.
|
|
"""
|
|
# Assign __tablename__ = "campaign_tests"
|
|
__tablename__ = "campaign_tests"
|
|
|
|
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
# Assign campaign_id = Column(
|
|
campaign_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
|
# Keyword argument: nullable
|
|
nullable=False,
|
|
)
|
|
# Assign test_id = Column(
|
|
test_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tests.id", ondelete="CASCADE"),
|
|
# Keyword argument: nullable
|
|
nullable=False,
|
|
)
|
|
# Assign order_index = Column(Integer, nullable=False, default=0)
|
|
order_index = Column(Integer, nullable=False, default=0)
|
|
# Assign depends_on = Column(
|
|
depends_on = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("campaign_tests.id", ondelete="SET NULL"),
|
|
# Keyword argument: nullable
|
|
nullable=True,
|
|
)
|
|
# Assign phase = Column(String, nullable=True) # kill chain phase
|
|
phase = Column(String, nullable=True) # kill chain phase
|
|
|
|
# Relationships
|
|
campaign = relationship("Campaign", back_populates="campaign_tests")
|
|
# Assign test = relationship("Test")
|
|
test = relationship("Test")
|
|
# Assign dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
|
|
dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
|
|
|
|
# Assign __table_args__ = (
|
|
__table_args__ = (
|
|
Index('ix_campaign_tests_campaign', 'campaign_id'),
|
|
Index('ix_campaign_tests_test', 'test_id'),
|
|
)
|
|
|
|
|
|
class CampaignModificationRequest(Base):
|
|
"""A lead's request to add or remove a test from an already-active campaign.
|
|
|
|
The underlying ``CampaignTest`` row is only created/deleted once a
|
|
manager approves the request — see ``approve_modification_request``
|
|
in ``campaign_crud_service.py``.
|
|
"""
|
|
__tablename__ = "campaign_modification_requests"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
campaign_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
requested_by = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
action = Column(String, nullable=False) # add_test, remove_test
|
|
# SET NULL (not CASCADE): approving a "remove_test" request deletes the
|
|
# underlying Test row (see remove_test_from_campaign), and this request
|
|
# row is the audit record of that decision — it must survive the test's
|
|
# deletion, not be wiped out along with it.
|
|
test_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("tests.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
order_index = Column(Integer, nullable=True)
|
|
phase = Column(String, nullable=True)
|
|
justification = Column(Text, nullable=False)
|
|
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
|
|
reviewed_by = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
reviewed_at = Column(DateTime, nullable=True)
|
|
review_notes = Column(Text, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
campaign = relationship("Campaign")
|
|
test = relationship("Test")
|
|
requester = relationship("User", foreign_keys=[requested_by])
|
|
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
|
|
|
__table_args__ = (
|
|
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
|
|
Index("ix_campaign_mod_requests_status", "status"),
|
|
)
|