"""Campaign scheduler service — recurring campaign execution. Handles checking which recurring campaigns are due, cloning them with fresh tests, and computing the next run date. """ # Import logging import logging # Import datetime, timedelta from datetime from datetime import datetime, timedelta # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import Campaign, CampaignTest from app.models.campaign from app.models.campaign import Campaign, CampaignTest # Import TestState from app.models.enums from app.models.enums import TestState # Import Test from app.models.test from app.models.test import Test # Import User from app.models.user from app.models.user import User # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import create_notification from app.services.notification_service from app.services.notification_service import create_notification # Assign logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Next-run calculation # --------------------------------------------------------------------------- def calculate_next_run(current_date: datetime, pattern: str) -> datetime: """Compute the next run date from *current_date* and a recurrence pattern. Supported patterns: - ``weekly`` : +7 days - ``monthly`` : +30 days - ``quarterly``: +90 days """ # Assign offsets = { offsets = { # Literal argument value "weekly": timedelta(days=7), # Literal argument value "monthly": timedelta(days=30), # Literal argument value "quarterly": timedelta(days=90), } # Return current_date + offsets.get(pattern, timedelta(days=30)) return current_date + offsets.get(pattern, timedelta(days=30)) # --------------------------------------------------------------------------- # Clone a campaign # --------------------------------------------------------------------------- def _clone_campaign(db: Session, original: Campaign) -> Campaign: """Create a new child campaign from a recurring template. 1. Clone the campaign with a date-stamped name. 2. For each ``CampaignTest`` in the original, create a new ``Test`` with the same base data (in ``draft`` state) and link it. 3. Activate the new campaign. """ # Assign now = datetime.utcnow() now = datetime.utcnow() # Assign run_label = now.strftime("%Y-%m-%d") run_label = now.strftime("%Y-%m-%d") # Assign child = Campaign( child = Campaign( # Keyword argument: name name=f"{original.name} (Run {run_label})", # Keyword argument: description description=original.description, # Keyword argument: type type=original.type, # Keyword argument: threat_actor_id threat_actor_id=original.threat_actor_id, # Keyword argument: status status="active", # Keyword argument: created_by created_by=original.created_by, # Keyword argument: target_platform target_platform=original.target_platform, # Keyword argument: tags tags=original.tags or [], # Keyword argument: parent_campaign_id parent_campaign_id=original.id, ) # Stage new record(s) for database insertion db.add(child) # Flush changes to DB without committing the transaction db.flush() # get child.id # Clone each campaign_test with a fresh Test original_cts = ( db.query(CampaignTest) # Chain .filter() call .filter(CampaignTest.campaign_id == original.id) # Chain .order_by() call .order_by(CampaignTest.order_index) # Chain .all() call .all() ) # Iterate over original_cts for ct in original_cts: # Assign src_test = ct.test src_test = ct.test # Check: not src_test if not src_test: # Skip to the next loop iteration continue # Assign new_test = Test( new_test = Test( # Keyword argument: technique_id technique_id=src_test.technique_id, # Keyword argument: name name=src_test.name, # Keyword argument: description description=src_test.description, # Keyword argument: platform platform=src_test.platform, # Keyword argument: procedure_text procedure_text=src_test.procedure_text, # Keyword argument: tool_used tool_used=src_test.tool_used, # Keyword argument: created_by created_by=original.created_by, # Keyword argument: state state=TestState.draft, ) # Stage new record(s) for database insertion db.add(new_test) # Flush changes to DB without committing the transaction db.flush() # get new_test.id # Assign new_ct = CampaignTest( new_ct = CampaignTest( # Keyword argument: campaign_id campaign_id=child.id, # Keyword argument: test_id test_id=new_test.id, # Keyword argument: order_index order_index=ct.order_index, # Keyword argument: phase phase=ct.phase, # depends_on is not copied — would need ID remapping ) # Stage new record(s) for database insertion db.add(new_ct) # Flush changes to DB without committing the transaction db.flush() # Return child return child # --------------------------------------------------------------------------- # Check and run recurring campaigns (daily job) # --------------------------------------------------------------------------- def check_and_run_recurring_campaigns(db: Session) -> int: """Check all recurring campaigns and clone any that are due. Returns the number of campaigns spawned. """ # Assign now = datetime.utcnow() now = datetime.utcnow() # Assign due_campaigns = ( due_campaigns = ( db.query(Campaign) # Chain .filter() call .filter( Campaign.is_recurring == True, # noqa: E712 Campaign.next_run_at <= now, ) # Chain .all() call .all() ) # Assign spawned = 0 spawned = 0 # Iterate over due_campaigns for campaign in due_campaigns: # Attempt the following; catch errors below try: # Assign child = _clone_campaign(db, campaign) child = _clone_campaign(db, campaign) # Update the original's scheduling fields campaign.last_run_at = now # Assign campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly") campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly") # Commit all pending changes to the database db.commit() # Reload ORM object attributes from the database db.refresh(child) # Audit log_action( db, # Keyword argument: user_id user_id=campaign.created_by, # Keyword argument: action action="recurring_campaign_run", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=child.id, # Keyword argument: details details={ # Literal argument value "parent_campaign_id": str(campaign.id), # Literal argument value "child_campaign_name": child.name, # Literal argument value "pattern": campaign.recurrence_pattern, }, ) # Commit all pending changes to the database db.commit() # Notify if campaign.created_by: # Call create_notification() create_notification( db, # Keyword argument: user_id user_id=campaign.created_by, # Keyword argument: type type="recurring_campaign_run", # Keyword argument: title title="Recurring campaign executed", # Keyword argument: message message=( f'Campaign "{child.name}" was automatically created ' f'from recurring template "{campaign.name}".' ), # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=child.id, ) # Notify red_tech users red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712 # Iterate over red_techs for user in red_techs: # Call create_notification() create_notification( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: type type="campaign_activated", # Keyword argument: title title="New recurring campaign active", # Keyword argument: message message=f'Campaign "{child.name}" is now active and ready for execution.', # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=child.id, ) # Assign spawned = 1 spawned += 1 # Log info: "Spawned child campaign '%s' from parent '%s'", ch logger.info("Spawned child campaign '%s' from parent '%s'", child.name, campaign.name) # Handle Exception except Exception: # Roll back all uncommitted changes db.rollback() # Log exception: "Failed to run recurring campaign '%s'", campaign. logger.exception("Failed to run recurring campaign '%s'", campaign.name) # Return spawned return spawned