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 app/api/definitions/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ paths:
/api/attack-objects:
$ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects'

/api/attack-objects/{stixId}/deprecation-check:
$ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects~1{stixId}~1deprecation-check'
# Generate next available ATT&CK ID
/api/attack-objects/attack-id/next:
$ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects~1attack-id~1next'
Expand Down
4 changes: 2 additions & 2 deletions app/api/definitions/paths/assets-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -351,4 +351,4 @@ paths:
'404':
description: 'The asset or revoking object was not found.'
'409':
description: 'The asset is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
78 changes: 78 additions & 0 deletions app/api/definitions/paths/attack-objects-paths.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,82 @@
paths:
/api/attack-objects/{stixId}/deprecation-check:
get:
summary: Check current relationship blockers before deprecation
operationId: attack-object-deprecation-check
description: |
Authenticated authoritative latest-STIX graph preflight. Active latest SROs other
than subtechnique-of and revoked-by, and current domain embedded references in
either direction, block SDO deprecation, including inbound references from inactive
objects. Existing subtechnique-of and revoked-by SROs are preserved unchanged in
either direction. Historical revisions, retired SROs, attribution, markings and
collection inventory are excluded. Explicit retirement of any SRO itself always
returns empty blockers, including the two preserved types. These deprecation
exemptions do not change new relationship authoring guards or revocation workflows.
A successful check is not a write
authorization: POST rechecks under shared graph-write serialization and returns
409 with code deprecation_blocked and these same fields at the top level on conflict.
tags:
- ATT&CK Objects
parameters:
- name: stixId
in: path
required: true
schema:
type: string
responses:
'200':
description: Current deprecation eligibility and actionable references.
content:
application/json:
schema:
type: object
required: [stix_id, can_deprecate, blockers]
properties:
stix_id:
type: string
can_deprecate:
type: boolean
blockers:
type: object
required: [sros, embedded]
properties:
sros:
type: array
items:
type: object
required: [stix_id, modified, relationship_type, direction]
properties:
stix_id:
type: string
modified:
type: string
format: date-time
relationship_type:
type: string
direction:
type: string
enum: [inbound, outbound]
embedded:
type: array
items:
type: object
required: [source_ref, target_ref, path, direction]
properties:
source_ref:
type: string
target_ref:
type: string
path:
type: string
description: STIX-relative property path, including array indices.
direction:
type: string
enum: [inbound, outbound]
'401':
description: Authentication required.
'404':
description: No object with this STIX ID exists.

/api/attack-objects:
get:
summary: 'Get a list of all ATT&CK objects'
Expand Down
4 changes: 2 additions & 2 deletions app/api/definitions/paths/campaigns-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -331,4 +331,4 @@ paths:
'404':
description: 'The campaign or revoking object was not found.'
'409':
description: 'The campaign is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/data-components-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Incoming analytic log-source name/channel values and replacement references are retained. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -397,4 +397,4 @@ paths:
'404':
description: 'The data component or revoking object was not found.'
'409':
description: 'The data component is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/data-sources-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes. ADM relationship type constraints still apply; failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -367,4 +367,4 @@ paths:
'404':
description: 'The data source or revoking object was not found.'
'409':
description: 'The data source is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/groups-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -331,4 +331,4 @@ paths:
'404':
description: 'The group or revoking object was not found.'
'409':
description: 'The group is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/matrices-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes. ADM relationship type constraints still apply; failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -358,4 +358,4 @@ paths:
'404':
description: 'The matrix or revoking object was not found.'
'409':
description: 'The matrix is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/mitigations-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -343,4 +343,4 @@ paths:
'404':
description: 'The mitigation or revoking object was not found.'
'409':
description: 'The mitigation is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/software-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -353,4 +353,4 @@ paths:
'404':
description: 'The software or revoking object was not found.'
'409':
description: 'The software is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/tactics-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.'
description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes. ADM relationship type constraints still apply; failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -400,4 +400,4 @@ paths:
'404':
description: 'The tactic or revoking object was not found.'
'409':
description: 'The tactic is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
4 changes: 2 additions & 2 deletions app/api/definitions/paths/techniques-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ paths:
type: string
- name: preserveRelationships
in: query
description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deprecation.'
description: 'Preserve active ordinary SROs by cloning onto the active latest replacement (except subtechnique-of); retire originals as new deprecated revisions. Also substitute inbound domain embedded references and merge outgoing references without losing replacement values. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.'
schema:
type: boolean
default: false
Expand Down Expand Up @@ -512,4 +512,4 @@ paths:
'404':
description: 'The technique or revoking object was not found.'
'409':
description: 'The technique is already revoked.'
description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.'
8 changes: 8 additions & 0 deletions app/controllers/attack-objects-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ exports.retrieveAll = async function (req, res) {
return res.status(500).send('Unable to get ATT&CK objects. Server error.');
}
};

exports.deprecationCheck = async function (req, res, next) {
try {
return res.status(200).send(await attackObjectsService.deprecationCheck(req.params.stixId));
} catch (error) {
return next(error);
}
};
17 changes: 17 additions & 0 deletions app/exceptions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,21 @@ class ObjectHasValidationIssuesError extends CustomError {
}
}

class LifecycleConflictError extends CustomError {
constructor(message, options) {
super(message, options);
}
}

class DeprecationBlockedError extends LifecycleConflictError {
constructor(check) {
super('Remove current relationships before deprecating this object', {
code: 'deprecation_blocked',
...check,
});
}
}

module.exports = {
//** General errors */
NotImplementedError,
Expand All @@ -481,6 +496,8 @@ module.exports = {
//** Revocation errors */
AlreadyRevokedError,
SelfRevocationError,
LifecycleConflictError,
DeprecationBlockedError,

//** Version control errors */
AlreadyReleasedError,
Expand Down
113 changes: 113 additions & 0 deletions app/lib/domain-references.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use strict';

const _ = require('lodash');
const { LifecycleConflictError } = require('../exceptions');
const excluded = new Set([
'created_by_ref',
'x_mitre_modified_by_ref',
'object_marking_refs',
'granular_markings',
'marking_ref',
'x_mitre_contents',
'external_references',
]);
const isReference = (key) => /_refs?$/.test(key);

function references(stix) {
if (!stix || stix.type === 'relationship' || stix.type === 'marking-definition') return [];
const found = [];
function visit(value, path = '', reference = false) {
if (value?.toObject) value = value.toObject();
if (typeof value === 'string') {
if (reference && /^[a-z][a-z0-9-]*--[0-9a-f-]+$/i.test(value)) {
found.push({ source_ref: stix.id, target_ref: value, path });
}
} else if (Array.isArray(value)) {
value.forEach((item, index) => visit(item, `${path}[${index}]`, reference));
} else if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
if (!excluded.has(key)) visit(child, path ? `${path}.${key}` : key, isReference(key));
}
}
}
visit(stix);
return found;
}

function addedReferences(stix, previous) {
function signature(ref, object) {
// Include the containing record's metadata (log-source name/channel),
// ignoring only array position so reordering is not a new reference.
const recordPath = ref.path.match(/^(.*\[\d+\])\./)?.[1];
const record = recordPath ? _.get(object, recordPath) : null;
return {
path: ref.path.replace(/\[\d+\]/g, '[]'),
target: ref.target_ref,
record: record?.toObject ? record.toObject() : record,
};
}
const retained = references(previous).map((ref) => signature(ref, previous));
return references(stix).filter((ref) => {
const candidate = signature(ref, stix);
const index = retained.findIndex((existing) => _.isEqual(existing, candidate));
if (index < 0) return true;
retained.splice(index, 1);
return false;
});
}

function substitute(stix, from, to) {
const result = _.cloneDeep(stix);
for (const ref of references(stix)) {
if (ref.target_ref === from) _.set(result, ref.path, to);
}
const arrays = new Set();
for (const ref of references(result)) {
for (const match of ref.path.matchAll(/\[\d+\]/g)) arrays.add(ref.path.slice(0, match.index));
}
for (const path of [...arrays].sort((a, b) => b.length - a.length)) {
_.set(result, path, _.uniqWith(_.get(result, path), _.isEqual));
}
return result;
}

// Union only reference-bearing properties, preserving the whole containing
// array entry (notably analytic log-source name and channel).
function mergeOutgoing(target, source, from, to) {
const incoming = substitute(source, from, to);
const result = _.cloneDeep(target);
const paths = references(incoming).map((ref) => ref.path);
const carriesReference = (path) =>
paths.some((p) => p === path || p.startsWith(`${path}.`) || p.startsWith(`${path}[`));
function merge(existing, addition, path) {
if (existing === undefined) return _.cloneDeep(addition);
if (Array.isArray(addition) && Array.isArray(existing)) {
const union = _.cloneDeep(existing);
for (const item of addition) {
if (!union.some((other) => _.isEqual(other, item))) union.push(_.cloneDeep(item));
}
return union;
}
if (_.isPlainObject(addition) && _.isPlainObject(existing)) {
const merged = _.cloneDeep(existing);
for (const [key, value] of Object.entries(addition)) {
const childPath = `${path}.${key}`;
if (carriesReference(childPath)) merged[key] = merge(merged[key], value, childPath);
}
return merged;
}
if (_.isEqual(existing, addition)) return existing;
throw new LifecycleConflictError('Replacement has a conflicting embedded reference', {
code: 'embedded_reference_conflict',
path,
source_ref: from,
target_ref: to,
});
}
for (const [key, value] of Object.entries(incoming)) {
if (carriesReference(key)) result[key] = merge(result[key], value, key);
}
return result;
}

module.exports = { references, addedReferences, substitute, mergeOutgoing };
Loading
Loading