fix: 4 improvements — campaign test deletion, review queue triggers, technique link, Jira read-only
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
1. Campaign test deletion: removing a test from a campaign now also
deletes the underlying Test record and recalculates technique status.
2. Review Queue triggers: review_required=True is now also set when
- Sigma/Elastic detection rules are imported for a technique
- A test is validated (coverage status changes)
3. Test detail — Technique link: 'Technique' entry added at the top of
the Details sidebar showing MITRE ID + name as a clickable link to
/techniques/{mitre_id}.
4. Jira panel — read-only on test page: added readOnly + label props to
JiraLinkPanel. TestDetailPage now passes readOnly=true and the test
name as label, hiding Link Issue / Sync / Unlink controls (automatic
Jira creation only — no manual management).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -508,6 +508,9 @@ def validate_red(
|
||||
)
|
||||
if test.state in (TestState.validated, TestState.rejected):
|
||||
recalculate_technique_status(db, test.technique)
|
||||
# Flag technique for review — coverage changed
|
||||
if test.technique:
|
||||
test.technique.review_required = True
|
||||
uow.commit()
|
||||
db.refresh(test)
|
||||
if test.state == TestState.validated:
|
||||
@@ -539,6 +542,9 @@ def validate_blue(
|
||||
)
|
||||
if test.state in (TestState.validated, TestState.rejected):
|
||||
recalculate_technique_status(db, test.technique)
|
||||
# Flag technique for review — coverage changed
|
||||
if test.technique:
|
||||
test.technique.review_required = True
|
||||
uow.commit()
|
||||
db.refresh(test)
|
||||
if test.state == TestState.validated:
|
||||
|
||||
@@ -320,9 +320,28 @@ def remove_test_from_campaign(db: Session, campaign_id: str, campaign_test_id: s
|
||||
for dep in dependents:
|
||||
dep.depends_on = None
|
||||
|
||||
# Keep a reference to the underlying test before deleting the join record
|
||||
test_id = ct.test_id
|
||||
technique_id = None
|
||||
test_obj = db.query(Test).filter(Test.id == test_id).first()
|
||||
if test_obj:
|
||||
technique_id = test_obj.technique_id
|
||||
|
||||
db.delete(ct)
|
||||
db.flush()
|
||||
|
||||
# Also delete the actual test record (it was created for this campaign)
|
||||
if test_obj:
|
||||
db.delete(test_obj)
|
||||
db.flush()
|
||||
|
||||
# Recalculate technique status_global so coverage metrics stay consistent
|
||||
if technique_id:
|
||||
technique = db.query(Technique).filter(Technique.id == technique_id).first()
|
||||
if technique:
|
||||
recalculate_technique_status(db, technique)
|
||||
db.flush()
|
||||
|
||||
|
||||
def activate_campaign(db: Session, campaign_id: str) -> Campaign:
|
||||
"""Activate a campaign, moving it from draft to active.
|
||||
|
||||
@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.detection_rule import DetectionRule
|
||||
from app.models.data_source import DataSource
|
||||
from app.models.technique import Technique
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -316,6 +317,7 @@ def sync(db: Session) -> dict:
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
new_technique_ids: set[str] = set()
|
||||
|
||||
for item in parsed_rules:
|
||||
if item["source_id"] in existing_ids:
|
||||
@@ -337,8 +339,15 @@ def sync(db: Session) -> dict:
|
||||
)
|
||||
db.add(rule)
|
||||
existing_ids.add(item["source_id"])
|
||||
new_technique_ids.add(item["mitre_technique_id"])
|
||||
created += 1
|
||||
|
||||
# Flag techniques that received new rules for review
|
||||
if new_technique_ids:
|
||||
db.query(Technique).filter(
|
||||
Technique.mitre_id.in_(new_technique_ids)
|
||||
).update({"review_required": True}, synchronize_session=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
summary = {
|
||||
|
||||
@@ -37,6 +37,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.detection_rule import DetectionRule
|
||||
from app.models.data_source import DataSource
|
||||
from app.models.technique import Technique
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -288,6 +289,7 @@ def sync(db: Session) -> dict:
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
new_technique_ids: set[str] = set()
|
||||
|
||||
for item in parsed_rules:
|
||||
# Dedup key: source_id (relative path). A rule file may produce
|
||||
@@ -316,8 +318,15 @@ def sync(db: Session) -> dict:
|
||||
)
|
||||
db.add(rule)
|
||||
existing_ids.add(item["source_id"])
|
||||
new_technique_ids.add(item["mitre_technique_id"])
|
||||
created += 1
|
||||
|
||||
# Flag techniques that received new rules for review
|
||||
if new_technique_ids:
|
||||
db.query(Technique).filter(
|
||||
Technique.mitre_id.in_(new_technique_ids)
|
||||
).update({"review_required": True}, synchronize_session=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
summary = {
|
||||
|
||||
@@ -26,6 +26,10 @@ import { useDebounce } from "../hooks/useDebounce";
|
||||
interface JiraLinkPanelProps {
|
||||
entityType: JiraLinkEntityType;
|
||||
entityId: string;
|
||||
/** If true, hides all management controls (Link Issue, Sync, Unlink). */
|
||||
readOnly?: boolean;
|
||||
/** Optional label shown under the Jira header (e.g. the test name). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
@@ -42,7 +46,7 @@ const statusColors: Record<string, string> = {
|
||||
"Done": "bg-green-900/50 text-green-400",
|
||||
};
|
||||
|
||||
export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelProps) {
|
||||
export default function JiraLinkPanel({ entityType, entityId, readOnly = false, label }: JiraLinkPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -120,29 +124,36 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Link2 className="h-5 w-5 text-blue-400" />
|
||||
Jira
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{showSearch ? (
|
||||
<>
|
||||
<X className="h-3.5 w-3.5" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" /> Link Issue
|
||||
</>
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Link2 className="h-5 w-5 text-blue-400" />
|
||||
Jira
|
||||
</h2>
|
||||
{label && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 truncate max-w-xs">{label}</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{showSearch ? (
|
||||
<>
|
||||
<X className="h-3.5 w-3.5" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" /> Link Issue
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search panel */}
|
||||
{showSearch && (
|
||||
{/* Search panel — only in edit mode */}
|
||||
{!readOnly && showSearch && (
|
||||
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
@@ -245,16 +256,18 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(link.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
title="Sync from Jira"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(link.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
title="Sync from Jira"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`${jiraBaseUrl}/browse/${link.jira_issue_key}`}
|
||||
target="_blank"
|
||||
@@ -264,14 +277,16 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(link.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Unlink"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(link.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Unlink"
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -440,6 +440,26 @@ export default function TestDetailPage() {
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Details</h2>
|
||||
<dl className="space-y-4">
|
||||
{test.technique_mitre_id && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase text-gray-500">Technique</dt>
|
||||
<dd className="mt-1">
|
||||
<button
|
||||
onClick={() => navigate(`/techniques/${test.technique_mitre_id}`)}
|
||||
className="flex items-start gap-1.5 text-left group"
|
||||
>
|
||||
<span className="font-mono text-xs text-cyan-400 shrink-0 group-hover:underline">
|
||||
{test.technique_mitre_id}
|
||||
</span>
|
||||
{test.technique_name && (
|
||||
<span className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors">
|
||||
\u2014 {test.technique_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase text-gray-500">Description</dt>
|
||||
<dd className="mt-1">
|
||||
@@ -539,7 +559,7 @@ export default function TestDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraLinkPanel entityType="test" entityId={testId!} />
|
||||
<JiraLinkPanel entityType="test" entityId={testId!} readOnly label={test.name} />
|
||||
|
||||
{/* Phase Timeline (read-only, with Tempo sync) */}
|
||||
<TestPhaseTimeline test={test} testId={testId} />
|
||||
|
||||
Reference in New Issue
Block a user