feat(campaigns): start_date for threat-actor-generated campaigns
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user