From 1b513b050e0d45ad86220641960f6999d1c6b580 Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 29 May 2026 11:18:55 +0200 Subject: [PATCH] =?UTF-8?q?fix:=204=20improvements=20=E2=80=94=20campaign?= =?UTF-8?q?=20test=20deletion,=20review=20queue=20triggers,=20technique=20?= =?UTF-8?q?link,=20Jira=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/routers/tests.py | 6 ++ backend/app/services/campaign_crud_service.py | 19 ++++ .../app/services/elastic_import_service.py | 9 ++ backend/app/services/sigma_import_service.py | 9 ++ frontend/src/components/JiraLinkPanel.tsx | 93 +++++++++++-------- frontend/src/pages/TestDetailPage.tsx | 22 ++++- 6 files changed, 118 insertions(+), 40 deletions(-) diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 21d5b4a..2da6958 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -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: diff --git a/backend/app/services/campaign_crud_service.py b/backend/app/services/campaign_crud_service.py index 3dc7714..25102b7 100644 --- a/backend/app/services/campaign_crud_service.py +++ b/backend/app/services/campaign_crud_service.py @@ -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. diff --git a/backend/app/services/elastic_import_service.py b/backend/app/services/elastic_import_service.py index 75b2b59..b5799ed 100644 --- a/backend/app/services/elastic_import_service.py +++ b/backend/app/services/elastic_import_service.py @@ -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 = { diff --git a/backend/app/services/sigma_import_service.py b/backend/app/services/sigma_import_service.py index 7872874..e67a5a1 100644 --- a/backend/app/services/sigma_import_service.py +++ b/backend/app/services/sigma_import_service.py @@ -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 = { diff --git a/frontend/src/components/JiraLinkPanel.tsx b/frontend/src/components/JiraLinkPanel.tsx index 07f3c46..c5e1a9d 100644 --- a/frontend/src/components/JiraLinkPanel.tsx +++ b/frontend/src/components/JiraLinkPanel.tsx @@ -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 = { @@ -42,7 +46,7 @@ const statusColors: Record = { "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 (
-
-

- - Jira -

- +
+ {!readOnly && ( + + )}
- {/* Search panel */} - {showSearch && ( + {/* Search panel — only in edit mode */} + {!readOnly && showSearch && (
@@ -245,16 +256,18 @@ export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelPro
- + {!readOnly && ( + + )} - + {!readOnly && ( + + )}
diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index da6e5e7..8582b39 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -440,6 +440,26 @@ export default function TestDetailPage() {

Details

+ {test.technique_mitre_id && ( +
+
Technique
+
+ +
+
+ )}
Description
@@ -539,7 +559,7 @@ export default function TestDetailPage() { )} {/* Jira Integration */} - + {/* Phase Timeline (read-only, with Tempo sync) */}