feat(jira): label standalone tests, include round data in submission comments
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Tests created without a campaign parent ticket now get a "standalone-test" label, so they're identifiable in Jira without cross-referencing Aegis. - The comment posted on every red/blue submission was a generic one-liner with no data — only a reopened round got a full data dump (via push_round_archived), so a round that was approved outright left no record in Jira of what was actually done. Now every submission comment includes the round number, procedure/tool/attack success (red) or detection/containment result (blue), and summary.
This commit is contained in:
@@ -449,15 +449,26 @@ def _build_state_comment(
|
|||||||
|
|
||||||
elif new_state == "red_review":
|
elif new_state == "red_review":
|
||||||
lines += [
|
lines += [
|
||||||
"Red Team has submitted evidence and the test is awaiting Red Lead review "
|
f"Red Team has submitted evidence for Round {test.red_round_number or 1} and the "
|
||||||
"before it queues for Blue Team.",
|
"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":
|
elif new_state == "blue_review":
|
||||||
lines += [
|
lines += [
|
||||||
"Blue Team has submitted evidence and the test is awaiting Blue Lead review "
|
f"Blue Team has submitted evidence for Round {test.blue_round_number or 1} and the "
|
||||||
"before cross-validation.",
|
"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":
|
elif new_state == "blue_evaluating":
|
||||||
lines += [
|
lines += [
|
||||||
@@ -707,6 +718,11 @@ def auto_create_test_issue(
|
|||||||
if test.platform:
|
if test.platform:
|
||||||
# Jira labels can't contain whitespace — normalize to kebab-case.
|
# Jira labels can't contain whitespace — normalize to kebab-case.
|
||||||
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
|
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 = {
|
fields: dict = {
|
||||||
"project": {"key": project_key},
|
"project": {"key": project_key},
|
||||||
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ def _make_test(**overrides):
|
|||||||
t.containment_time = None
|
t.containment_time = None
|
||||||
t.red_round_number = 1
|
t.red_round_number = 1
|
||||||
t.blue_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
|
# _build_state_comment() appends these raw (not via an f-string) when
|
||||||
# truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines).
|
# truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines).
|
||||||
t.red_summary = None
|
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)
|
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.has_admin_jira_configured", return_value=True)
|
||||||
@patch("app.services.jira_service.get_admin_jira_client")
|
@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):
|
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:
|
if expected_label:
|
||||||
assert expected_label in labels
|
assert expected_label in labels
|
||||||
else:
|
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):
|
def _make_user(**overrides):
|
||||||
|
|||||||
Reference in New Issue
Block a user