diff --git a/AGENTS.md b/AGENTS.md index f17c64c..ac820fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,10 @@ ## Build, Test, and Development Commands +- Use `uv` to manage the project virtual environment, and keep it at the repo root as `.venv/`. - `just install`: install all dependencies (dev + docs) via `uv sync --all-extras`. - `just setup-hooks`: install `pre-commit` hooks (including commit message validation). +- Before committing, run `just lint`. - `just lint`: run pre-commit hooks across the repo. - `just test`: run the pytest suite. - `just test-cov`: run tests with coverage for `mitreattack`. diff --git a/mitreattack/diffStix/changelog_helper.py b/mitreattack/diffStix/changelog_helper.py index a7cf85b..5428f26 100644 --- a/mitreattack/diffStix/changelog_helper.py +++ b/mitreattack/diffStix/changelog_helper.py @@ -896,10 +896,16 @@ def get_groupings(self, object_type: str, stix_objects: List, section: str, doma # now group parents and children groupings = [] + # Track every child emitted into a grouping so the orphan-preservation fallback + # only adds truly ungrouped objects. This matters because the normal grouping + # path mutates parentToChildren via pop(), so a later scan of that mapping would + # miss children that were already attached to a valid parent and re-add them. + assigned_child_ids = set() for parent_stix_object in childless + parents: child_objects = ( parentToChildren.pop(parent_stix_object["id"]) if parent_stix_object["id"] in parentToChildren else [] ) + assigned_child_ids.update(child["id"] for child in child_objects) groupings.append( { "parent": parent_stix_object, @@ -916,6 +922,9 @@ def get_groupings(self, object_type: str, stix_objects: List, section: str, doma parent_stix_object = datasources[parent_stix_id] if parent_stix_object: + # Keep the children together under the missing-section parent context, + # but still mark them as assigned so they are not emitted again below. + assigned_child_ids.update(child["id"] for child in child_objects) groupings.append( { "parent": None, @@ -923,6 +932,31 @@ def get_groupings(self, object_type: str, stix_objects: List, section: str, doma "sort_name": parent_stix_object["name"], } ) + else: + for child in child_objects: + # The relationship points to a parent we cannot resolve from the + # loaded bundle, so preserve each child as its own standalone entry. + assigned_child_ids.add(child["id"]) + groupings.append( + { + "parent": None, + "children": [child], + "sort_name": child["name"], + } + ) + + # Preserve children that never appeared in any grouping at all. This covers + # malformed or partial bundles where an object is marked as a sub-technique + # (or data component child) but no usable relationship was loaded. + for child in children.values(): + if child["id"] not in assigned_child_ids: + groupings.append( + { + "parent": None, + "children": [child], + "sort_name": child["name"], + } + ) groupings = sorted(groupings, key=lambda grouping: grouping["sort_name"]) return groupings diff --git a/tests/changelog/core/test_diffstix_methods.py b/tests/changelog/core/test_diffstix_methods.py index b875df1..814e1c2 100644 --- a/tests/changelog/core/test_diffstix_methods.py +++ b/tests/changelog/core/test_diffstix_methods.py @@ -62,6 +62,55 @@ def test_get_groupings_omits_parent_when_not_in_section( assert result[0]["parent"] is None assert result[0]["children"] == [sample_subtechnique_object] + def test_get_groupings_keeps_orphan_subtechnique_when_relationship_missing( + self, + lightweight_diffstix, + sample_subtechnique_object, + ): + """Test orphaned sub-techniques remain visible when no relationship exists.""" + lightweight_diffstix.data["new"]["enterprise-attack"]["attack_objects"]["techniques"] = { + sample_subtechnique_object["id"]: sample_subtechnique_object, + } + lightweight_diffstix.data["new"]["enterprise-attack"]["relationships"]["subtechniques"] = {} + + result = lightweight_diffstix.get_groupings( + object_type="techniques", + stix_objects=[sample_subtechnique_object], + section="revocations", + domain="enterprise-attack", + ) + + assert len(result) == 1 + assert result[0]["parent"] is None + assert result[0]["children"] == [sample_subtechnique_object] + + def test_get_groupings_does_not_duplicate_child_with_parent_relationship( + self, + lightweight_diffstix, + sample_technique_object, + sample_subtechnique_object, + sample_subtechnique_of_technique_relationship, + ): + """Test children grouped under a real parent are not re-added by fallback handling.""" + lightweight_diffstix.data["new"]["enterprise-attack"]["attack_objects"]["techniques"] = { + sample_technique_object["id"]: sample_technique_object, + sample_subtechnique_object["id"]: sample_subtechnique_object, + } + lightweight_diffstix.data["new"]["enterprise-attack"]["relationships"]["subtechniques"] = { + sample_subtechnique_of_technique_relationship["id"]: sample_subtechnique_of_technique_relationship, + } + + result = lightweight_diffstix.get_groupings( + object_type="techniques", + stix_objects=[sample_technique_object, sample_subtechnique_object], + section="additions", + domain="enterprise-attack", + ) + + assert len(result) == 1 + assert result[0]["parent"] == sample_technique_object + assert result[0]["children"] == [sample_subtechnique_object] + def test_update_contributors_real_functionality(self, lightweight_diffstix, mock_stix_object_factory): """Test real contributor tracking.""" # Create objects with contributors