feat(campaigns): start_date for threat-actor-generated campaigns
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Backend:
- campaign_service.generate_campaign_from_threat_actor: accept optional
  start_date kwarg and set it on the Campaign model
- campaigns router: new GenerateFromActorPayload schema, /from-threat-actor
  endpoint now accepts optional body with start_date

Frontend:
- generateCampaignFromThreatActor API: accept optional options param
- Generate Campaign modal: date picker + warning message, same UX as the
  manual create form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-04 13:37:40 +02:00
parent 498536f3f1
commit f8418bc7ea
4 changed files with 48 additions and 3 deletions

View File

@@ -395,9 +395,14 @@ def get_campaign_progress_endpoint(
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign # POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel):
start_date: Optional[str] = None # ISO date YYYY-MM-DD
@router.post("/from-threat-actor/{actor_id}", status_code=201) @router.post("/from-threat-actor/{actor_id}", status_code=201)
def generate_campaign_from_actor( def generate_campaign_from_actor(
actor_id: str, actor_id: str,
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
@@ -406,10 +411,14 @@ def generate_campaign_from_actor(
Creates tests from the best available templates and orders them Creates tests from the best available templates and orders them
by kill chain phase. by kill chain phase.
""" """
start_date_parsed = (
datetime.fromisoformat(payload.start_date) if payload.start_date else None
)
campaign = generate_campaign_from_threat_actor( campaign = generate_campaign_from_threat_actor(
db, db,
uuid.UUID(actor_id), uuid.UUID(actor_id),
current_user, current_user,
start_date=start_date_parsed,
) )
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:

View File

@@ -7,6 +7,7 @@ threat actors, and progress calculation.
import logging import logging
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -106,6 +107,8 @@ def generate_campaign_from_threat_actor(
db: Session, db: Session,
actor_id: uuid.UUID, actor_id: uuid.UUID,
user: User, user: User,
*,
start_date: Optional[datetime] = None,
) -> Campaign: ) -> Campaign:
"""Auto-generate a campaign from a threat actor's uncovered techniques. """Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -146,6 +149,7 @@ def generate_campaign_from_threat_actor(
status="draft", status="draft",
created_by=user.id, created_by=user.id,
tags=[actor.name, "auto-generated"], tags=[actor.name, "auto-generated"],
start_date=start_date,
) )
db.add(campaign) db.add(campaign)
db.flush() # Get campaign.id db.flush() # Get campaign.id

View File

@@ -155,8 +155,14 @@ export async function getCampaignProgress(campaignId: string): Promise<CampaignP
} }
/** Generate a campaign from a threat actor. */ /** Generate a campaign from a threat actor. */
export async function generateCampaignFromThreatActor(actorId: string): Promise<Campaign> { export async function generateCampaignFromThreatActor(
const { data } = await client.post<Campaign>(`/campaigns/from-threat-actor/${actorId}`); actorId: string,
options?: { start_date?: string },
): Promise<Campaign> {
const { data } = await client.post<Campaign>(
`/campaigns/from-threat-actor/${actorId}`,
options ?? {},
);
return data; return data;
} }

View File

@@ -49,6 +49,7 @@ export default function CampaignsPage() {
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [showActorSelector, setShowActorSelector] = useState(false); const [showActorSelector, setShowActorSelector] = useState(false);
const [actorSearch, setActorSearch] = useState(""); const [actorSearch, setActorSearch] = useState("");
const [actorStartDate, setActorStartDate] = useState("");
const [newCampaign, setNewCampaign] = useState({ const [newCampaign, setNewCampaign] = useState({
name: "", name: "",
description: "", description: "",
@@ -87,10 +88,12 @@ export default function CampaignsPage() {
}); });
const generateMutation = useMutation({ const generateMutation = useMutation({
mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId), mutationFn: (actorId: string) =>
generateCampaignFromThreatActor(actorId, actorStartDate ? { start_date: actorStartDate } : undefined),
onSuccess: (campaign) => { onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] }); queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowActorSelector(false); setShowActorSelector(false);
setActorStartDate("");
navigate(`/campaigns/${campaign.id}`); navigate(`/campaigns/${campaign.id}`);
}, },
}); });
@@ -289,6 +292,29 @@ export default function CampaignsPage() {
/> />
</div> </div>
{/* Start date */}
<div className="mb-4">
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Start date
<span className="ml-2 text-xs font-normal text-gray-500">
(optional — campaign activates automatically on this date)
</span>
</label>
<input
type="date"
value={actorStartDate}
min={new Date().toISOString().split("T")[0]}
onChange={(e) => setActorStartDate(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none [color-scheme:dark]"
/>
{actorStartDate && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-amber-400">
<span>⏰</span>
Tests won't be queued until {new Date(actorStartDate + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}
</p>
)}
</div>
{/* Actor list */} {/* Actor list */}
<div className="max-h-72 overflow-y-auto rounded-lg border border-gray-800"> <div className="max-h-72 overflow-y-auto rounded-lg border border-gray-800">
{isLoadingActors ? ( {isLoadingActors ? (