Skip to content

Commit e14b106

Browse files
committed
feat(validation): enforce unique object IDs in STIX bundles
Add createUniqueObjectsOnlyRefinement to validate that all objects in a STIX bundle have unique IDs. Includes comprehensive test coverage and removes unused helper code from generics.ts.
1 parent a8c83fd commit e14b106

3 files changed

Lines changed: 296 additions & 1 deletion

File tree

src/refinements/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,40 @@ export function createFirstBundleObjectRefinement() {
190190
};
191191
}
192192

193+
/**
194+
* Creates a refinement function for validating that all objects in a STIX bundle have unique IDs
195+
*
196+
* @returns A refinement function for unique object ID validation
197+
*
198+
* @remarks
199+
* This function validates that each object in the bundle's 'objects' array has a unique 'id' property.
200+
* Duplicate IDs violate STIX specifications and can cause data integrity issues.
201+
*
202+
* @example
203+
* ```typescript
204+
* const validateUniqueObjects = createUniqueObjectsOnlyRefinement();
205+
* const schema = stixBundleSchema.check(validateUniqueObjects);
206+
* ```
207+
*/
208+
export function createUniqueObjectsOnlyRefinement() {
209+
return (ctx: z.core.ParsePayload<StixBundle>): void => {
210+
const seen = new Set<string>();
211+
ctx.value.objects.forEach((item, index) => {
212+
const id = (item as AttackObject).id;
213+
if (seen.has(id)) {
214+
ctx.issues.push({
215+
code: 'custom',
216+
message: `Duplicate object with id "${id}" found. Each object in the bundle must have a unique id.`,
217+
path: ['objects', index, 'id'],
218+
input: id,
219+
});
220+
} else {
221+
seen.add(id);
222+
}
223+
});
224+
};
225+
}
226+
193227
/**
194228
* Creates a refinement function for validating ATT&CK ID in external references
195229
*

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { z } from 'zod/v4';
2-
import { createFirstBundleObjectRefinement } from '../../refinements/index.js';
2+
import {
3+
createFirstBundleObjectRefinement,
4+
createUniqueObjectsOnlyRefinement,
5+
} from '../../refinements/index.js';
36
import {
47
createStixIdValidator,
58
createStixTypeValidator,
@@ -189,6 +192,7 @@ export const stixBundleSchema = z
189192
.strict()
190193
.check((ctx) => {
191194
createFirstBundleObjectRefinement()(ctx);
195+
createUniqueObjectsOnlyRefinement()(ctx);
192196
});
193197

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

test/objects/stix-bundle.test.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,263 @@ describe('StixBundleSchema', () => {
171171

172172
expect(() => stixBundleSchema.parse(invalidFirstObjectBundle)).toThrow();
173173
});
174+
175+
describe('Uniqueness Constraint', () => {
176+
it('should accept bundle with unique object IDs (true positive)', () => {
177+
const technique1: Technique = {
178+
id: `attack-pattern--${uuidv4()}`,
179+
type: 'attack-pattern',
180+
spec_version: '2.1',
181+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
182+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
183+
name: 'Test Technique 1',
184+
x_mitre_attack_spec_version: '2.1.0',
185+
x_mitre_version: '1.0',
186+
x_mitre_domains: ['enterprise-attack'],
187+
x_mitre_is_subtechnique: false,
188+
external_references: [
189+
{
190+
source_name: 'mitre-attack',
191+
external_id: 'T1001',
192+
},
193+
],
194+
};
195+
196+
const technique2: Technique = {
197+
id: `attack-pattern--${uuidv4()}`,
198+
type: 'attack-pattern',
199+
spec_version: '2.1',
200+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
201+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
202+
name: 'Test Technique 2',
203+
x_mitre_attack_spec_version: '2.1.0',
204+
x_mitre_version: '1.0',
205+
x_mitre_domains: ['enterprise-attack'],
206+
x_mitre_is_subtechnique: false,
207+
external_references: [
208+
{
209+
source_name: 'mitre-attack',
210+
external_id: 'T1002',
211+
},
212+
],
213+
};
214+
215+
const bundleWithUniqueObjects = {
216+
...minimalBundle,
217+
objects: [minimalCollection, technique1, technique2],
218+
};
219+
220+
expect(() => stixBundleSchema.parse(bundleWithUniqueObjects)).not.toThrow();
221+
});
222+
223+
it('should reject bundle with duplicate object IDs (true negative)', () => {
224+
const duplicateId = `attack-pattern--${uuidv4()}`;
225+
226+
const technique1: Technique = {
227+
id: duplicateId,
228+
type: 'attack-pattern',
229+
spec_version: '2.1',
230+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
231+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
232+
name: 'Test Technique 1',
233+
x_mitre_attack_spec_version: '2.1.0',
234+
x_mitre_version: '1.0',
235+
x_mitre_domains: ['enterprise-attack'],
236+
x_mitre_is_subtechnique: false,
237+
external_references: [
238+
{
239+
source_name: 'mitre-attack',
240+
external_id: 'T1001',
241+
},
242+
],
243+
};
244+
245+
const technique2: Technique = {
246+
id: duplicateId, // Same ID as technique1
247+
type: 'attack-pattern',
248+
spec_version: '2.1',
249+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
250+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
251+
name: 'Test Technique 2',
252+
x_mitre_attack_spec_version: '2.1.0',
253+
x_mitre_version: '1.0',
254+
x_mitre_domains: ['enterprise-attack'],
255+
x_mitre_is_subtechnique: false,
256+
external_references: [
257+
{
258+
source_name: 'mitre-attack',
259+
external_id: 'T1002',
260+
},
261+
],
262+
};
263+
264+
const bundleWithDuplicateObjects = {
265+
...minimalBundle,
266+
objects: [minimalCollection, technique1, technique2],
267+
};
268+
269+
expect(() => stixBundleSchema.parse(bundleWithDuplicateObjects)).toThrow(
270+
/Duplicate object with id/,
271+
);
272+
});
273+
274+
it('should report the duplicate ID in error message', () => {
275+
const duplicateId = `attack-pattern--${uuidv4()}`;
276+
277+
const technique1: Technique = {
278+
id: duplicateId,
279+
type: 'attack-pattern',
280+
spec_version: '2.1',
281+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
282+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
283+
name: 'Test Technique 1',
284+
x_mitre_attack_spec_version: '2.1.0',
285+
x_mitre_version: '1.0',
286+
x_mitre_domains: ['enterprise-attack'],
287+
x_mitre_is_subtechnique: false,
288+
external_references: [
289+
{
290+
source_name: 'mitre-attack',
291+
external_id: 'T1001',
292+
},
293+
],
294+
};
295+
296+
const technique2: Technique = {
297+
id: duplicateId,
298+
type: 'attack-pattern',
299+
spec_version: '2.1',
300+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
301+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
302+
name: 'Test Technique 2',
303+
x_mitre_attack_spec_version: '2.1.0',
304+
x_mitre_version: '1.0',
305+
x_mitre_domains: ['enterprise-attack'],
306+
x_mitre_is_subtechnique: false,
307+
external_references: [
308+
{
309+
source_name: 'mitre-attack',
310+
external_id: 'T1002',
311+
},
312+
],
313+
};
314+
315+
const bundleWithDuplicateObjects = {
316+
...minimalBundle,
317+
objects: [minimalCollection, technique1, technique2],
318+
};
319+
320+
try {
321+
stixBundleSchema.parse(bundleWithDuplicateObjects);
322+
expect.fail('Expected schema to throw for duplicate IDs');
323+
} catch (error) {
324+
if (error instanceof z.ZodError) {
325+
const errorMessage = error.issues[0].message;
326+
expect(errorMessage).toContain(duplicateId);
327+
} else {
328+
throw error;
329+
}
330+
}
331+
});
332+
333+
it('should handle multiple duplicates in a single bundle', () => {
334+
const duplicateId1 = `attack-pattern--${uuidv4()}`;
335+
const duplicateId2 = `attack-pattern--${uuidv4()}`;
336+
337+
const technique1: Technique = {
338+
id: duplicateId1,
339+
type: 'attack-pattern',
340+
spec_version: '2.1',
341+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
342+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
343+
name: 'Test Technique 1',
344+
x_mitre_attack_spec_version: '2.1.0',
345+
x_mitre_version: '1.0',
346+
x_mitre_domains: ['enterprise-attack'],
347+
x_mitre_is_subtechnique: false,
348+
external_references: [
349+
{
350+
source_name: 'mitre-attack',
351+
external_id: 'T1001',
352+
},
353+
],
354+
};
355+
356+
const technique2: Technique = {
357+
id: duplicateId1, // Duplicate of technique1
358+
type: 'attack-pattern',
359+
spec_version: '2.1',
360+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
361+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
362+
name: 'Test Technique 2',
363+
x_mitre_attack_spec_version: '2.1.0',
364+
x_mitre_version: '1.0',
365+
x_mitre_domains: ['enterprise-attack'],
366+
x_mitre_is_subtechnique: false,
367+
external_references: [
368+
{
369+
source_name: 'mitre-attack',
370+
external_id: 'T1002',
371+
},
372+
],
373+
};
374+
375+
const technique3: Technique = {
376+
id: duplicateId2,
377+
type: 'attack-pattern',
378+
spec_version: '2.1',
379+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
380+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
381+
name: 'Test Technique 3',
382+
x_mitre_attack_spec_version: '2.1.0',
383+
x_mitre_version: '1.0',
384+
x_mitre_domains: ['enterprise-attack'],
385+
x_mitre_is_subtechnique: false,
386+
external_references: [
387+
{
388+
source_name: 'mitre-attack',
389+
external_id: 'T1003',
390+
},
391+
],
392+
};
393+
394+
const technique4: Technique = {
395+
id: duplicateId2, // Duplicate of technique3
396+
type: 'attack-pattern',
397+
spec_version: '2.1',
398+
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
399+
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
400+
name: 'Test Technique 4',
401+
x_mitre_attack_spec_version: '2.1.0',
402+
x_mitre_version: '1.0',
403+
x_mitre_domains: ['enterprise-attack'],
404+
x_mitre_is_subtechnique: false,
405+
external_references: [
406+
{
407+
source_name: 'mitre-attack',
408+
external_id: 'T1004',
409+
},
410+
],
411+
};
412+
413+
const bundleWithMultipleDuplicates = {
414+
...minimalBundle,
415+
objects: [minimalCollection, technique1, technique2, technique3, technique4],
416+
};
417+
418+
try {
419+
stixBundleSchema.parse(bundleWithMultipleDuplicates);
420+
expect.fail('Expected schema to throw for multiple duplicate IDs');
421+
} catch (error) {
422+
if (error instanceof z.ZodError) {
423+
// Should have at least 2 errors (one for each duplicate pair)
424+
expect(error.issues.length).toBeGreaterThanOrEqual(2);
425+
} else {
426+
throw error;
427+
}
428+
}
429+
});
430+
});
174431
});
175432

176433
// GitHub Actions often fails without an increased timeout for this test

0 commit comments

Comments
 (0)