Skip to content

Commit 2c75c7d

Browse files
authored
Merge pull request #63 from mitre-attack/62-request-implement-uniqueness-validation-for-stix-bundle-objects
Implement uniqueness validation for STIX bundle objects
2 parents a8c83fd + 21d054c commit 2c75c7d

File tree

8 files changed

+1150
-50
lines changed

8 files changed

+1150
-50
lines changed

src/refinements/index.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
attackIdPatterns,
66
type Aliases,
77
type AttackObject,
8+
type Collection,
89
type ExternalReferences,
910
type KillChainPhase,
1011
type StixBundle,
@@ -190,6 +191,167 @@ export function createFirstBundleObjectRefinement() {
190191
};
191192
}
192193

194+
/**
195+
* Creates a refinement function for validating that objects in an array have no duplicates
196+
* based on specified keys
197+
*
198+
* @param arrayPath - The path to the array property in the context value (e.g., ['objects']). Use [] for direct array validation.
199+
* @param keys - The keys to use for duplicate detection (e.g., ['id'] or ['source_name', 'external_id']). Use [] for primitive arrays.
200+
* @param errorMessage - Optional custom error message template. Use {keys} for key values, {value} for primitives, and {index} for position
201+
* @returns A refinement function for duplicate validation
202+
*
203+
* @remarks
204+
* This function validates that objects in an array are unique based on one or more key fields.
205+
* It creates a composite key from the specified fields and checks for duplicates.
206+
*
207+
* **Supports three validation modes:**
208+
* 1. Object arrays with single key: `keys = ['id']`
209+
* 2. Object arrays with composite keys: `keys = ['source_name', 'external_id']`
210+
* 3. Primitive arrays: `keys = []` (validates the values themselves)
211+
*
212+
* @example
213+
* ```typescript
214+
* // Single key validation
215+
* const validateUniqueIds = validateNoDuplicates(['objects'], ['id']);
216+
* const schema = baseSchema.check(validateUniqueIds);
217+
*
218+
* // Composite key validation
219+
* const validateUniqueRefs = validateNoDuplicates(
220+
* ['external_references'],
221+
* ['source_name', 'external_id'],
222+
* 'Duplicate reference found with source_name="{source_name}" and external_id="{external_id}"'
223+
* );
224+
*
225+
* // Primitive array validation (e.g., array of strings)
226+
* const validateUniqueStrings = validateNoDuplicates(
227+
* [],
228+
* [],
229+
* 'Duplicate value "{value}" found'
230+
* );
231+
* ```
232+
*/
233+
export function validateNoDuplicates(arrayPath: string[], keys: string[], errorMessage?: string) {
234+
return (ctx: z.core.ParsePayload<unknown>): void => {
235+
// Navigate to the array using the path
236+
let arr: unknown = ctx.value;
237+
for (const pathSegment of arrayPath) {
238+
if (arr && typeof arr === 'object') {
239+
arr = (arr as Record<string, unknown>)[pathSegment];
240+
} else {
241+
return;
242+
}
243+
}
244+
245+
// If array doesn't exist or is not an array, skip validation
246+
if (!Array.isArray(arr)) {
247+
return;
248+
}
249+
250+
const seen = new Map<string, number>();
251+
252+
arr.forEach((item, index) => {
253+
// Create composite key from specified keys
254+
// If keys array is empty, treat each item as a primitive value
255+
const keyValues =
256+
keys.length === 0
257+
? [String(item)]
258+
: keys.map((key) => {
259+
const value = item?.[key];
260+
return value !== undefined ? String(value) : '';
261+
});
262+
const compositeKey = keyValues.join('||');
263+
264+
if (seen.has(compositeKey)) {
265+
// Build key-value pairs for error message
266+
const keyValuePairs = keys.reduce(
267+
(acc, key, i) => {
268+
acc[key] = keyValues[i];
269+
return acc;
270+
},
271+
{} as Record<string, string>,
272+
);
273+
274+
// Generate error message
275+
let message = errorMessage;
276+
if (!message) {
277+
if (keys.length === 0) {
278+
// Primitive array (no keys)
279+
message = `Duplicate value "${keyValues[0]}" found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`;
280+
} else if (keys.length === 1) {
281+
message = `Duplicate object with ${keys[0]}="${keyValues[0]}" found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`;
282+
} else {
283+
const keyPairs = keys.map((key, i) => `${key}="${keyValues[i]}"`).join(', ');
284+
message = `Duplicate object with ${keyPairs} found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`;
285+
}
286+
} else {
287+
// Replace placeholders in custom message
288+
message = message.replace(/\{(\w+)\}/g, (match, key) => {
289+
if (key === 'index') return String(index);
290+
if (key === 'value' && keys.length === 0) return keyValues[0];
291+
return keyValuePairs[key] ?? match;
292+
});
293+
}
294+
295+
ctx.issues.push({
296+
code: 'custom',
297+
message,
298+
path: keys.length === 0 ? [...arrayPath, index] : [...arrayPath, index, ...keys],
299+
input: keys.length === 0 ? item : keys.length === 1 ? item?.[keys[0]] : keyValuePairs,
300+
});
301+
} else {
302+
seen.set(compositeKey, index);
303+
}
304+
});
305+
};
306+
}
307+
308+
/**
309+
* Creates a refinement function for validating that all STIX IDs referenced in x_mitre_contents
310+
* exist in the bundle's objects array
311+
*
312+
* @returns A refinement function for x_mitre_contents reference validation
313+
*
314+
* @remarks
315+
* This function validates that every STIX ID referenced in the collection's x_mitre_contents
316+
* property (which acts as a table of contents for the bundle) has a corresponding object
317+
* in the bundle's objects array. This ensures referential integrity within the bundle.
318+
*
319+
* The function expects:
320+
* - The first object in the bundle to be a Collection (x-mitre-collection type)
321+
* - Each object_ref in x_mitre_contents to match an id in the objects array
322+
*
323+
* @example
324+
* ```typescript
325+
* const schema = stixBundleSchema.check(validateXMitreContentsReferences());
326+
* ```
327+
*/
328+
export function validateXMitreContentsReferences() {
329+
return (ctx: z.core.ParsePayload<StixBundle>): void => {
330+
// Get the collection object (first object in bundle)
331+
const collectionObject = ctx.value.objects[0];
332+
const collectionContents = (collectionObject as Collection).x_mitre_contents;
333+
334+
if (!collectionContents) {
335+
return;
336+
}
337+
338+
// Create a set of all object IDs in the bundle for efficient lookup
339+
const objectIds = new Set(ctx.value.objects.map((obj) => (obj as AttackObject).id));
340+
341+
// Validate each reference in x_mitre_contents
342+
collectionContents.forEach((contentRef: { object_ref: string }, index: number) => {
343+
if (!objectIds.has(contentRef.object_ref)) {
344+
ctx.issues.push({
345+
code: 'custom',
346+
message: `STIX ID "${contentRef.object_ref}" referenced in x_mitre_contents is not present in the bundle's objects array`,
347+
path: ['objects', 0, 'x_mitre_contents', index, 'object_ref'],
348+
input: contentRef.object_ref,
349+
});
350+
}
351+
});
352+
};
353+
}
354+
193355
/**
194356
* Creates a refinement function for validating ATT&CK ID in external references
195357
*

src/schemas/sdo/analytic.schema.ts

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
xMitreModifiedByRefSchema,
1111
xMitrePlatformsSchema,
1212
} from '../common/property-schemas/index.js';
13+
import { validateNoDuplicates } from '../../refinements/index.js';
1314

1415
//==============================================================================
1516
//
@@ -46,28 +47,15 @@ export type LogSourceReference = z.infer<typeof xMitreLogSourceReferenceSchema>;
4647
export const xMitreLogSourceReferencesSchema = z
4748
.array(xMitreLogSourceReferenceSchema)
4849
.min(1)
49-
.refine(
50-
// Reject duplicate log source references, delineated by (x_mitre_data_component_ref, name, channel)
51-
// An analytic cannot reference the same log source twice
52-
(logSourceReferences) => {
53-
const seenRefs = new Set<string>();
54-
55-
for (const logSourceRef of logSourceReferences) {
56-
const key = `${logSourceRef.x_mitre_data_component_ref}|${logSourceRef.name}|${logSourceRef.channel}`;
57-
if (seenRefs.has(key)) {
58-
return false;
59-
}
60-
seenRefs.add(key);
61-
}
62-
63-
return true;
64-
},
65-
{
66-
message:
67-
'Duplicate log source reference found: each (x_mitre_data_component_ref, name, channel) tuple must be unique',
68-
path: ['x_mitre_log_source_references'],
69-
},
70-
)
50+
.check((ctx) => {
51+
// Validate no duplicate log source references using composite key validation
52+
// Each (x_mitre_data_component_ref, name, channel) tuple must be unique
53+
validateNoDuplicates(
54+
[],
55+
['x_mitre_data_component_ref', 'name', 'channel'],
56+
'Duplicate log source reference found: each (x_mitre_data_component_ref, name, channel) tuple must be unique',
57+
)(ctx);
58+
})
7159
.meta({
7260
description:
7361
'A list of log source references, which are delineated by a Data Component STIX ID and the (`name`, `channel`) that is being targeted.',

src/schemas/sdo/data-component.schema.ts

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
xMitreDomainsSchema,
1111
xMitreModifiedByRefSchema,
1212
} from '../common/property-schemas/index.js';
13+
import { validateNoDuplicates } from '../../refinements/index.js';
1314

1415
//==============================================================================
1516
//
@@ -46,28 +47,15 @@ export const xMitreLogSourcesSchema = z
4647
.strict(),
4748
)
4849
.min(1)
49-
.refine(
50-
// Reject duplicate (name, channel) pairs
51-
// Allow same name with different channels
52-
// Allow same channel with different names
53-
(permutations) => {
54-
const seen = new Set<string>();
55-
56-
for (const perm of permutations) {
57-
const key = `${perm.name}|${perm.channel}`;
58-
if (seen.has(key)) {
59-
return false;
60-
}
61-
seen.add(key);
62-
}
63-
64-
return true;
65-
},
66-
{
67-
message: 'Duplicate log source found: each (name, channel) pair must be unique',
68-
path: ['x_mitre_log_sources'],
69-
},
70-
)
50+
.check((ctx) => {
51+
// Validate no duplicate (name, channel) pairs using composite key validation
52+
// Allow same name with different channels, and same channel with different names
53+
validateNoDuplicates(
54+
[],
55+
['name', 'channel'],
56+
'Duplicate log source found: each (name, channel) pair must be unique',
57+
)(ctx);
58+
})
7159
.meta({
7260
description: `
7361
The \`log_source\` object defines platform-specific collection configurations embedded within data components:

src/schemas/sdo/detection-strategy.schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
xMitreDomainsSchema,
99
xMitreModifiedByRefSchema,
1010
} from '../common/property-schemas/index.js';
11+
import { validateNoDuplicates } from '../../refinements/index.js';
1112

1213
//==============================================================================
1314
//
@@ -30,6 +31,14 @@ export const detectionStrategySchema = attackBaseDomainObjectSchema
3031
x_mitre_analytic_refs: z
3132
.array(createStixIdValidator('x-mitre-analytic'))
3233
.nonempty({ error: 'At least one analytic ref is required' })
34+
.check((ctx) => {
35+
// Validate no duplicate analytic references using primitive array validation
36+
validateNoDuplicates(
37+
[],
38+
[],
39+
'Duplicate reference "{value}" found. Each embedded relationship referenced in x_mitre_analytic_refs must be unique.',
40+
)(ctx);
41+
})
3342
.meta({
3443
description:
3544
'Array of STIX IDs referencing `x-mitre-analytic` objects that implement this detection strategy.',

src/schemas/sdo/stix-bundle.schema.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { z } from 'zod/v4';
2-
import { createFirstBundleObjectRefinement } from '../../refinements/index.js';
2+
import {
3+
createFirstBundleObjectRefinement,
4+
validateNoDuplicates,
5+
validateXMitreContentsReferences,
6+
} from '../../refinements/index.js';
37
import {
48
createStixIdValidator,
59
createStixTypeValidator,
@@ -188,7 +192,18 @@ export const stixBundleSchema = z
188192
})
189193
.strict()
190194
.check((ctx) => {
195+
// Validate that the first object in the 'objects' array is of type 'x-mitre-collection'
191196
createFirstBundleObjectRefinement()(ctx);
197+
198+
// Validate that all IDs referenced in 'x_mitre_contents' are present in 'objects' array
199+
validateXMitreContentsReferences()(ctx);
200+
201+
// Validate that no duplicate objects are present in 'objects' array
202+
validateNoDuplicates(
203+
['objects'],
204+
['id'],
205+
'Duplicate object with id "{id}" found. Each object in the bundle must have a unique id.',
206+
)(ctx);
192207
});
193208

194209
export type StixBundle = z.infer<typeof stixBundleSchema>;

test/objects/detection-strategy.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,14 +226,14 @@ describe('detectionStrategySchema', () => {
226226
});
227227

228228
describe('Edge Cases and Special Scenarios', () => {
229-
it('should handle duplicate analytic IDs', () => {
229+
it('should reject duplicate analytic IDs', () => {
230230
const analyticId = `x-mitre-analytic--${uuidv4()}`;
231231
const detectionStrategyWithDuplicates: DetectionStrategy = {
232232
...minimalDetectionStrategy,
233233
x_mitre_analytic_refs: [analyticId, analyticId, analyticId],
234234
};
235-
// Schema doesn't prevent duplicates, so this should pass
236-
expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).not.toThrow();
235+
// Schema prevents duplicates, so this should fail
236+
expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).toThrow();
237237
});
238238

239239
it('should handle large number of analytics', () => {

0 commit comments

Comments
 (0)