Skip to content

Commit 15aa166

Browse files
committed
fix: enhance grouping logic to preserve orphaned sub-techniques and prevent duplication
1 parent c8c6307 commit 15aa166

2 files changed

Lines changed: 91 additions & 8 deletions

File tree

mitreattack/diffStix/changelog_helper.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,10 +896,16 @@ def get_groupings(self, object_type: str, stix_objects: List, section: str, doma
896896

897897
# now group parents and children
898898
groupings = []
899+
# Track every child emitted into a grouping so the orphan-preservation fallback
900+
# only adds truly ungrouped objects. This matters because the normal grouping
901+
# path mutates parentToChildren via pop(), so a later scan of that mapping would
902+
# miss children that were already attached to a valid parent and re-add them.
903+
assigned_child_ids = set()
899904
for parent_stix_object in childless + parents:
900905
child_objects = (
901906
parentToChildren.pop(parent_stix_object["id"]) if parent_stix_object["id"] in parentToChildren else []
902907
)
908+
assigned_child_ids.update(child["id"] for child in child_objects)
903909
groupings.append(
904910
{
905911
"parent": parent_stix_object,
@@ -916,13 +922,41 @@ def get_groupings(self, object_type: str, stix_objects: List, section: str, doma
916922
parent_stix_object = datasources[parent_stix_id]
917923

918924
if parent_stix_object:
925+
# Keep the children together under the missing-section parent context,
926+
# but still mark them as assigned so they are not emitted again below.
927+
assigned_child_ids.update(child["id"] for child in child_objects)
919928
groupings.append(
920929
{
921930
"parent": None,
922931
"children": child_objects,
923932
"sort_name": parent_stix_object["name"],
924933
}
925934
)
935+
else:
936+
for child in child_objects:
937+
# The relationship points to a parent we cannot resolve from the
938+
# loaded bundle, so preserve each child as its own standalone entry.
939+
assigned_child_ids.add(child["id"])
940+
groupings.append(
941+
{
942+
"parent": None,
943+
"children": [child],
944+
"sort_name": child["name"],
945+
}
946+
)
947+
948+
# Preserve children that never appeared in any grouping at all. This covers
949+
# malformed or partial bundles where an object is marked as a sub-technique
950+
# (or data component child) but no usable relationship was loaded.
951+
for child in children.values():
952+
if child["id"] not in assigned_child_ids:
953+
groupings.append(
954+
{
955+
"parent": None,
956+
"children": [child],
957+
"sort_name": child["name"],
958+
}
959+
)
926960

927961
groupings = sorted(groupings, key=lambda grouping: grouping["sort_name"])
928962
return groupings

tests/changelog/core/test_diffstix_methods.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ def test_get_groupings_real_functionality(
3535
assert "children" in grouping
3636
assert isinstance(grouping["children"], list)
3737

38-
def test_get_groupings_omits_parent_when_not_in_section(
39-
self,
40-
lightweight_diffstix,
41-
sample_technique_object,
42-
sample_subtechnique_object,
38+
def test_get_groupings_omits_parent_when_not_in_section(
39+
self,
40+
lightweight_diffstix,
41+
sample_technique_object,
42+
sample_subtechnique_object,
4343
sample_subtechnique_of_technique_relationship,
4444
):
4545
"""Test groupings keep child context without rendering a parent outside the section."""
@@ -58,9 +58,58 @@ def test_get_groupings_omits_parent_when_not_in_section(
5858
domain="enterprise-attack",
5959
)
6060

61-
assert len(result) == 1
62-
assert result[0]["parent"] is None
63-
assert result[0]["children"] == [sample_subtechnique_object]
61+
assert len(result) == 1
62+
assert result[0]["parent"] is None
63+
assert result[0]["children"] == [sample_subtechnique_object]
64+
65+
def test_get_groupings_keeps_orphan_subtechnique_when_relationship_missing(
66+
self,
67+
lightweight_diffstix,
68+
sample_subtechnique_object,
69+
):
70+
"""Test orphaned sub-techniques remain visible when no relationship exists."""
71+
lightweight_diffstix.data["new"]["enterprise-attack"]["attack_objects"]["techniques"] = {
72+
sample_subtechnique_object["id"]: sample_subtechnique_object,
73+
}
74+
lightweight_diffstix.data["new"]["enterprise-attack"]["relationships"]["subtechniques"] = {}
75+
76+
result = lightweight_diffstix.get_groupings(
77+
object_type="techniques",
78+
stix_objects=[sample_subtechnique_object],
79+
section="revocations",
80+
domain="enterprise-attack",
81+
)
82+
83+
assert len(result) == 1
84+
assert result[0]["parent"] is None
85+
assert result[0]["children"] == [sample_subtechnique_object]
86+
87+
def test_get_groupings_does_not_duplicate_child_with_parent_relationship(
88+
self,
89+
lightweight_diffstix,
90+
sample_technique_object,
91+
sample_subtechnique_object,
92+
sample_subtechnique_of_technique_relationship,
93+
):
94+
"""Test children grouped under a real parent are not re-added by fallback handling."""
95+
lightweight_diffstix.data["new"]["enterprise-attack"]["attack_objects"]["techniques"] = {
96+
sample_technique_object["id"]: sample_technique_object,
97+
sample_subtechnique_object["id"]: sample_subtechnique_object,
98+
}
99+
lightweight_diffstix.data["new"]["enterprise-attack"]["relationships"]["subtechniques"] = {
100+
sample_subtechnique_of_technique_relationship["id"]: sample_subtechnique_of_technique_relationship,
101+
}
102+
103+
result = lightweight_diffstix.get_groupings(
104+
object_type="techniques",
105+
stix_objects=[sample_technique_object, sample_subtechnique_object],
106+
section="additions",
107+
domain="enterprise-attack",
108+
)
109+
110+
assert len(result) == 1
111+
assert result[0]["parent"] == sample_technique_object
112+
assert result[0]["children"] == [sample_subtechnique_object]
64113

65114
def test_update_contributors_real_functionality(self, lightweight_diffstix, mock_stix_object_factory):
66115
"""Test real contributor tracking."""

0 commit comments

Comments
 (0)