diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index a1e6459..f8c78f2 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -449,15 +449,26 @@ def _build_state_comment( elif new_state == "red_review": lines += [ - "Red Team has submitted evidence and the test is awaiting Red Lead review " - "before it queues for Blue Team.", + f"Red Team has submitted evidence for Round {test.red_round_number or 1} and the " + "test is awaiting Red Lead review before it queues for Blue Team.", + "", + f"*Procedure:* {test.procedure_text or 'N/A'}", + f"*Tool used:* {test.tool_used or 'N/A'}", + f"*Attack Success:* {_enum_value(test.attack_success) or 'N/A'}", ] + if test.red_summary: + lines += ["", "h4. Red Team Summary", test.red_summary] elif new_state == "blue_review": lines += [ - "Blue Team has submitted evidence and the test is awaiting Blue Lead review " - "before cross-validation.", + f"Blue Team has submitted evidence for Round {test.blue_round_number or 1} and the " + "test is awaiting Blue Lead review before cross-validation.", + "", + f"*Detection Result:* {_enum_value(test.detection_result) or 'N/A'}", + f"*Containment Result:* {_enum_value(test.containment_result) or 'N/A'}", ] + if test.blue_summary: + lines += ["", "h4. Blue Team Summary", test.blue_summary] elif new_state == "blue_evaluating": lines += [ @@ -707,6 +718,11 @@ def auto_create_test_issue( if test.platform: # Jira labels can't contain whitespace — normalize to kebab-case. labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-")) + # No parent_ticket_override means this test isn't nested under a + # campaign ticket — i.e. it's standalone. Tag it so standalone tests + # are identifiable in Jira without cross-referencing Aegis. + if parent_ticket_override is None: + labels.append("standalone-test") fields: dict = { "project": {"key": project_key}, "summary": f"[Aegis] {mitre_id} — {test.name}", diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index c9bb717..a3362c9 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -59,6 +59,8 @@ def _make_test(**overrides): t.containment_time = None t.red_round_number = 1 t.blue_round_number = 1 + t.procedure_text = None + t.tool_used = None # _build_state_comment() appends these raw (not via an f-string) when # truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines). t.red_summary = None @@ -230,6 +232,58 @@ def test_push_test_event_maps_every_state_to_jira_status( mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, expected_status) +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_test_event_red_review_comment_includes_round_data(mock_get_client, mock_configured, db): + """Every submission — not just ones that later get reopened — must leave + a data-bearing comment in Jira. Previously this comment was a generic + one-liner with no procedure/tool/result, so a round that got approved + (never archived via reopen) left no record of what was actually done.""" + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test( + red_round_number=2, + procedure_text="ran mimikatz again", tool_used="mimikatz", + attack_success=MagicMock(value="successful"), red_summary="Got a shell this time.", + ) + link = _make_link(test.id) + db.add(link) + db.commit() + + jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "red_review") + + comment = mock_jira.issue_add_comment.call_args[0][1] + assert "Round 2" in comment + assert "ran mimikatz again" in comment + assert "mimikatz" in comment + assert "successful" in comment + assert "Got a shell this time." in comment + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_test_event_blue_review_comment_includes_round_data(mock_get_client, mock_configured, db): + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test( + blue_round_number=1, + detection_result=MagicMock(value="detected"), + containment_result=MagicMock(value="contained"), + blue_summary="Caught it via EDR.", + ) + link = _make_link(test.id) + db.add(link) + db.commit() + + jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_review") + + comment = mock_jira.issue_add_comment.call_args[0][1] + assert "Round 1" in comment + assert "detected" in comment + assert "contained" in comment + assert "Caught it via EDR." in comment + + @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.get_admin_jira_client") def test_push_test_event_clears_assignee_on_in_review(mock_get_client, mock_configured, db): @@ -658,7 +712,61 @@ def test_auto_create_test_issue_adds_platform_label( if expected_label: assert expected_label in labels else: - assert len(labels) == 2 + # Just "aegis", the technique ID, and "standalone-test" (no + # parent_ticket_override passed here, same as any non-campaign test). + assert len(labels) == 3 + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_jira_project_key", return_value="SEC") +@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None) +@patch("app.services.jira_service.get_admin_jira_client") +def test_auto_create_test_issue_labels_standalone_tests( + mock_get_client, mock_parent, mock_project_key, mock_configured, db, +): + """A test created with no campaign parent ticket is standalone — tag it + so standalone tests are identifiable in Jira without cross-referencing + Aegis.""" + mock_jira = MagicMock() + mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"} + mock_get_client.return_value = mock_jira + from app.models.user import User + test = _make_test_for_issue() + technique = _make_technique() + actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x") + db.add(actor) + db.commit() + + jira_service.auto_create_test_issue(db, test, actor, technique=technique) + + labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"] + assert "standalone-test" in labels + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_jira_project_key", return_value="SEC") +@patch("app.services.jira_service.get_admin_jira_client") +def test_auto_create_test_issue_does_not_label_campaign_tests_as_standalone( + mock_get_client, mock_project_key, mock_configured, db, +): + """A campaign test passes parent_ticket_override — it must NOT get + "standalone-test", since it's nested under the campaign's ticket.""" + mock_jira = MagicMock() + mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"} + mock_get_client.return_value = mock_jira + from app.models.user import User + test = _make_test_for_issue() + technique = _make_technique() + actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x") + db.add(actor) + db.commit() + + jira_service.auto_create_test_issue( + db, test, actor, technique=technique, parent_ticket_override="CAMP-1", + ) + + labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"] + assert "standalone-test" not in labels def _make_user(**overrides):