Simple overview of ATT&CK v17 -> v18 changes

I watched your video about versioning in CTI Butler.

This feature is useful, but is there a way to get a high-level view of what has changes (vs. specific objects). Something similar to what ATT&CK Sync does?

This doesn’t exist in CTI Butler right now, but is possible if you want to work with the data locally with some of our tools to do this.

Let me explain how;

1. Setup local data

First, install stix2arango

Then download the ATT&CK data;

curl https://downloads.ctibutler.com/mitre-attack-enterprise-repo-data/enterprise-attack-17_0.json > enterprise-attack-17_0.json
curl https://downloads.ctibutler.com/mitre-attack-enterprise-repo-data/enterprise-attack-18_0.json > enterprise-attack-18_0.json

Now import it;

python3 stix2arango.py \
	--file enterprise-attack-17_0.json \
	--database demo \
	--collection attck \
	--stix2arango_note v17 && \
python3 stix2arango.py \
	--file enterprise-attack-18_0.json \
	--database demo \
	--collection attck \
	--stix2arango_note v18

2. Some sample searches to explore the data

The following searches give you the same data in ATT&CK sync.

Overview of ATT&CK object changes between version 17.0 and 18.0

FOR doc IN attck_vertex_collection
FILTER doc._stix2arango_note IN ["v17", "v18"]
COLLECT id = doc.id, type = doc.type INTO groupedDocs

LET versions = groupedDocs[*].doc
LET createdDates = UNIQUE(versions[*].created)
LET modifiedDates = UNIQUE(versions[*].modified)
LET count_versions = LENGTH(versions)

LET deprecatedInV18 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v18" AND v.x_mitre_deprecated == true RETURN v) > 0
LET deprecatedInV17 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v17" AND v.x_mitre_deprecated == true RETURN v) > 0

LET revokedInV18 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v18" AND v.revoked == true RETURN v) > 0
LET revokedInV17 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v17" AND v.revoked == true RETURN v) > 0

// Filter out results where x_mitre_deprecated or revoked is true in both versions
FILTER NOT (deprecatedInV17 == true AND deprecatedInV18 == true)
FILTER NOT (revokedInV17 == true AND revokedInV18 == true)

LET status = (
    count_versions == 1 ? "new" :
    (revokedInV18 == true AND revokedInV17 == false ? "revoked" :
    (deprecatedInV18 == true AND deprecatedInV17 == false ? "deprecated" :
    (LENGTH(createdDates) == 1 AND LENGTH(modifiedDates) == 1 ? "unchanged" : "changed")))
)

COLLECT finalStatus = status, finalType = type WITH COUNT INTO count

// Aggregate results by status
COLLECT status = finalStatus INTO typeCounts = {
  "type": finalType,
  "count": count
}

// Construct dynamic columns for each type
RETURN MERGE(
  { "status": status },
  ZIP(
    typeCounts[*].type,
    typeCounts[*].count
  )
)
[
  {
    "status": "changed",
    "x-mitre-tactic": 13,
    "x-mitre-data-component": 106,
    "tool": 30,
    "malware": 262,
    "intrusion-set": 58,
    "course-of-action": 2,
    "campaign": 3,
    "x-mitre-matrix": 1,
    "attack-pattern": 598
  },
  {
    "status": "deprecated",
    "x-mitre-data-source": 37
  },
  {
    "status": "new",
    "x-mitre-collection": 1,
    "x-mitre-detection-strategy": 691,
    "x-mitre-analytic": 1739,
    "tool": 1,
    "malware": 30,
    "intrusion-set": 8,
    "course-of-action": 17,
    "campaign": 5,
    "attack-pattern": 12
  },
  {
    "status": "unchanged",
    "x-mitre-tactic": 1,
    "tool": 61,
    "marking-definition": 1,
    "malware": 402,
    "intrusion-set": 108,
    "identity": 1,
    "course-of-action": 42,
    "campaign": 44,
    "attack-pattern": 81
  }
]

Get a list of all new objects

FOR doc IN attck_vertex_collection
  FILTER doc._stix2arango_note IN ["v17", "v18"]
  COLLECT id = doc.id INTO groupedDocs

  LET versions = groupedDocs[*].doc
  LET count_versions = LENGTH(versions)

  // Only include objects that are classified as "new" (present in only one version)
  FILTER count_versions == 1

  LET v = versions[0]

  // Safely handle missing/NULL external_references
  LET mitre_attack_id = FIRST(
    FOR ref IN (v.external_references || [])
      FILTER ref.source_name == "mitre-attack"
      RETURN ref.external_id
  )

  // Optionally skip docs with no mitre-attack ID
  FILTER mitre_attack_id != null

  RETURN {
    type: v.type,
    external_id: mitre_attack_id,
    name: v.name
  }

Get a list of all changed objects

FOR doc IN attck_vertex_collection
FILTER doc._stix2arango_note IN ["v17", "v18"]
COLLECT id = doc.id INTO groupedDocs

LET versions = groupedDocs[*].doc
LET createdDates = UNIQUE(versions[*].created)
LET modifiedDates = UNIQUE(versions[*].modified)
LET count_versions = LENGTH(versions)

LET deprecatedInV18 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v18.0" AND v.x_mitre_deprecated == true RETURN v) > 0
LET deprecatedInV17 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v17.0" AND v.x_mitre_deprecated == true RETURN v) > 0

LET revokedInV18 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v18.0" AND v.revoked == true RETURN v) > 0
LET revokedInV17 = LENGTH(FOR v IN versions FILTER v._stix2arango_note == "v17.0" AND v.revoked == true RETURN v) > 0

// Filter out results where x_mitre_deprecated or revoked is true in both versions
FILTER NOT (deprecatedInV17 == true AND deprecatedInV18 == true)
FILTER NOT (revokedInV17 == true AND revokedInV18 == true)

// Only include objects that are classified as "changed" (present in both versions with differences in created or modified dates)
LET status = (
    count_versions == 1 ? "new" :
    (revokedInV18 == true AND revokedInV17 == false ? "revoked" :
    (deprecatedInV18 == true AND deprecatedInV17 == false ? "deprecated" :
    (LENGTH(createdDates) == 1 AND LENGTH(modifiedDates) == 1 ? "unchanged" : "changed")))
)

FILTER status == "changed"

// Safely extract external_id where source_name is "mitre-attack", handling missing external_references
LET mitre_attack_id = FIRST(
    FOR ref IN versions[0].external_references || []
    FILTER ref.source_name == "mitre-attack"
    RETURN ref.external_id
)

// Return only the specified fields
RETURN {
    "type": versions[0].type,
    "external_id": mitre_attack_id,
    "name": versions[0].name
}

Compare changes to objects (a search similar to what we use in CTI Butler)

I’ll use T1053.005

LET target_id = "T1053.005"

LET docs = (
  FOR doc IN attck_vertex_collection
    FILTER doc._stix2arango_note IN ["v17", "v18"]  // <-- adjust to what you actually have
    AND "mitre-attack" IN (doc.external_references[*].source_name || [])
    AND target_id      IN (doc.external_references[*].external_id || [])
    RETURN { version: doc._stix2arango_note, doc }
)

LET v17_doc = FIRST(FOR d IN docs FILTER d.version == "v17" RETURN d.doc) || null
LET v18_doc = FIRST(FOR d IN docs FILTER d.version == "v18" RETURN d.doc) || null

RETURN (v17_doc != null AND v18_doc != null) ? {
  id: target_id,
  differences: (
    FOR key IN ATTRIBUTES(v17_doc)
      FILTER v17_doc[key] != v18_doc[key]
      AND !STARTS_WITH(key, "_")
      RETURN {
        field: key,
        v17: v17_doc[key],
        v18: v18_doc[key]
      }
  )
} : {
  id: target_id,
  error: "One or both versions are missing for comparison."
}