Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
34 changes: 34 additions & 0 deletions mitreattack/diffStix/changelog_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -916,13 +922,41 @@ 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,
"children": child_objects,
"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
Expand Down
49 changes: 49 additions & 0 deletions tests/changelog/core/test_diffstix_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down