From e14b106e19a56e894bc766c34f68291f8c3a4ce6 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:42:10 -0500 Subject: [PATCH 1/6] 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. --- src/refinements/index.ts | 34 ++++ src/schemas/sdo/stix-bundle.schema.ts | 6 +- test/objects/stix-bundle.test.ts | 257 ++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) diff --git a/src/refinements/index.ts b/src/refinements/index.ts index f0afc64f..96f1b735 100644 --- a/src/refinements/index.ts +++ b/src/refinements/index.ts @@ -190,6 +190,40 @@ export function createFirstBundleObjectRefinement() { }; } +/** + * Creates a refinement function for validating that all objects in a STIX bundle have unique IDs + * + * @returns A refinement function for unique object ID validation + * + * @remarks + * This function validates that each object in the bundle's 'objects' array has a unique 'id' property. + * Duplicate IDs violate STIX specifications and can cause data integrity issues. + * + * @example + * ```typescript + * const validateUniqueObjects = createUniqueObjectsOnlyRefinement(); + * const schema = stixBundleSchema.check(validateUniqueObjects); + * ``` + */ +export function createUniqueObjectsOnlyRefinement() { + return (ctx: z.core.ParsePayload): void => { + const seen = new Set(); + ctx.value.objects.forEach((item, index) => { + const id = (item as AttackObject).id; + if (seen.has(id)) { + ctx.issues.push({ + code: 'custom', + message: `Duplicate object with id "${id}" found. Each object in the bundle must have a unique id.`, + path: ['objects', index, 'id'], + input: id, + }); + } else { + seen.add(id); + } + }); + }; +} + /** * Creates a refinement function for validating ATT&CK ID in external references * diff --git a/src/schemas/sdo/stix-bundle.schema.ts b/src/schemas/sdo/stix-bundle.schema.ts index 97a29237..e006f073 100644 --- a/src/schemas/sdo/stix-bundle.schema.ts +++ b/src/schemas/sdo/stix-bundle.schema.ts @@ -1,5 +1,8 @@ import { z } from 'zod/v4'; -import { createFirstBundleObjectRefinement } from '../../refinements/index.js'; +import { + createFirstBundleObjectRefinement, + createUniqueObjectsOnlyRefinement, +} from '../../refinements/index.js'; import { createStixIdValidator, createStixTypeValidator, @@ -189,6 +192,7 @@ export const stixBundleSchema = z .strict() .check((ctx) => { createFirstBundleObjectRefinement()(ctx); + createUniqueObjectsOnlyRefinement()(ctx); }); export type StixBundle = z.infer; diff --git a/test/objects/stix-bundle.test.ts b/test/objects/stix-bundle.test.ts index bca84032..e14a41dd 100644 --- a/test/objects/stix-bundle.test.ts +++ b/test/objects/stix-bundle.test.ts @@ -171,6 +171,263 @@ describe('StixBundleSchema', () => { expect(() => stixBundleSchema.parse(invalidFirstObjectBundle)).toThrow(); }); + + describe('Uniqueness Constraint', () => { + it('should accept bundle with unique object IDs (true positive)', () => { + const technique1: Technique = { + id: `attack-pattern--${uuidv4()}`, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 1', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1001', + }, + ], + }; + + const technique2: Technique = { + id: `attack-pattern--${uuidv4()}`, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 2', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1002', + }, + ], + }; + + const bundleWithUniqueObjects = { + ...minimalBundle, + objects: [minimalCollection, technique1, technique2], + }; + + expect(() => stixBundleSchema.parse(bundleWithUniqueObjects)).not.toThrow(); + }); + + it('should reject bundle with duplicate object IDs (true negative)', () => { + const duplicateId = `attack-pattern--${uuidv4()}`; + + const technique1: Technique = { + id: duplicateId, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 1', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1001', + }, + ], + }; + + const technique2: Technique = { + id: duplicateId, // Same ID as technique1 + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 2', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1002', + }, + ], + }; + + const bundleWithDuplicateObjects = { + ...minimalBundle, + objects: [minimalCollection, technique1, technique2], + }; + + expect(() => stixBundleSchema.parse(bundleWithDuplicateObjects)).toThrow( + /Duplicate object with id/, + ); + }); + + it('should report the duplicate ID in error message', () => { + const duplicateId = `attack-pattern--${uuidv4()}`; + + const technique1: Technique = { + id: duplicateId, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 1', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1001', + }, + ], + }; + + const technique2: Technique = { + id: duplicateId, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 2', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1002', + }, + ], + }; + + const bundleWithDuplicateObjects = { + ...minimalBundle, + objects: [minimalCollection, technique1, technique2], + }; + + try { + stixBundleSchema.parse(bundleWithDuplicateObjects); + expect.fail('Expected schema to throw for duplicate IDs'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toContain(duplicateId); + } else { + throw error; + } + } + }); + + it('should handle multiple duplicates in a single bundle', () => { + const duplicateId1 = `attack-pattern--${uuidv4()}`; + const duplicateId2 = `attack-pattern--${uuidv4()}`; + + const technique1: Technique = { + id: duplicateId1, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 1', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1001', + }, + ], + }; + + const technique2: Technique = { + id: duplicateId1, // Duplicate of technique1 + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 2', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1002', + }, + ], + }; + + const technique3: Technique = { + id: duplicateId2, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 3', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1003', + }, + ], + }; + + const technique4: Technique = { + id: duplicateId2, // Duplicate of technique3 + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 4', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1004', + }, + ], + }; + + const bundleWithMultipleDuplicates = { + ...minimalBundle, + objects: [minimalCollection, technique1, technique2, technique3, technique4], + }; + + try { + stixBundleSchema.parse(bundleWithMultipleDuplicates); + expect.fail('Expected schema to throw for multiple duplicate IDs'); + } catch (error) { + if (error instanceof z.ZodError) { + // Should have at least 2 errors (one for each duplicate pair) + expect(error.issues.length).toBeGreaterThanOrEqual(2); + } else { + throw error; + } + } + }); + }); }); // GitHub Actions often fails without an increased timeout for this test From ab95e44ec2f4e0348c9b39e7275e60f8dbb3812e Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:36:26 -0500 Subject: [PATCH 2/6] feat(detection-strategy.schema): enforce unique keys in x_mitre_analytic_refs --- src/schemas/sdo/detection-strategy.schema.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/schemas/sdo/detection-strategy.schema.ts b/src/schemas/sdo/detection-strategy.schema.ts index f0974a8d..9aa6bf6f 100644 --- a/src/schemas/sdo/detection-strategy.schema.ts +++ b/src/schemas/sdo/detection-strategy.schema.ts @@ -30,6 +30,21 @@ export const detectionStrategySchema = attackBaseDomainObjectSchema x_mitre_analytic_refs: z .array(createStixIdValidator('x-mitre-analytic')) .nonempty({ error: 'At least one analytic ref is required' }) + .check((ctx) => { + const seen = new Set(); + ctx.value.forEach((analyticId, index) => { + if (seen.has(analyticId)) { + ctx.issues.push({ + code: 'custom', + message: `Duplicate reference "${analyticId}" found. Each embedded relationship referenced in x_mitre_analytic_refs must be unique.`, + path: ['x_mitre_analytic_refs', index], + input: analyticId, + }); + } else { + seen.add(analyticId); + } + }); + }) .meta({ description: 'Array of STIX IDs referencing `x-mitre-analytic` objects that implement this detection strategy.', From 1511500870953f189424cdba2d4e89f847311d6a Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:39:34 -0500 Subject: [PATCH 3/6] test(detection-strategy.schema): update tests to reflect unique vals only for x_mitre_analytic_refs --- test/objects/detection-strategy.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/objects/detection-strategy.test.ts b/test/objects/detection-strategy.test.ts index 17b60e19..69b24fbd 100644 --- a/test/objects/detection-strategy.test.ts +++ b/test/objects/detection-strategy.test.ts @@ -226,14 +226,14 @@ describe('detectionStrategySchema', () => { }); describe('Edge Cases and Special Scenarios', () => { - it('should handle duplicate analytic IDs', () => { + it('should reject duplicate analytic IDs', () => { const analyticId = `x-mitre-analytic--${uuidv4()}`; const detectionStrategyWithDuplicates: DetectionStrategy = { ...minimalDetectionStrategy, x_mitre_analytic_refs: [analyticId, analyticId, analyticId], }; - // Schema doesn't prevent duplicates, so this should pass - expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).not.toThrow(); + // Schema prevents duplicates, so this should fail + expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).toThrow(); }); it('should handle large number of analytics', () => { From 2d9c04ca15273479bfec8c12707d96babde2da04 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:08:01 -0500 Subject: [PATCH 4/6] refactor(validation): add generic validateNoDuplicates refinement and replace inline dupe checks Add a new validateNoDuplicates refinement factory function that provides flexible duplicate validation for: - Object arrays with single or composite keys - Primitive arrays (strings, numbers, etc.) - Nested array paths with custom error messages Replace inline duplicate checking logic in analytic, data-component, and detection-strategy schemas with the new generic refinement, improving code reuse and maintainability. The new function supports template-based error messages with placeholders for key values, primitive values, and array indices. Deprecate the existing createUniqueObjectsOnlyRefinement in favor of the more flexible approach. --- src/refinements/index.ts | 122 +++++ src/schemas/sdo/analytic.schema.ts | 32 +- src/schemas/sdo/data-component.schema.ts | 32 +- src/schemas/sdo/detection-strategy.schema.ts | 20 +- .../validate-no-duplicates.test.ts | 485 ++++++++++++++++++ 5 files changed, 634 insertions(+), 57 deletions(-) create mode 100644 test/refinements/validate-no-duplicates.test.ts diff --git a/src/refinements/index.ts b/src/refinements/index.ts index 96f1b735..b6322836 100644 --- a/src/refinements/index.ts +++ b/src/refinements/index.ts @@ -190,19 +190,141 @@ export function createFirstBundleObjectRefinement() { }; } +/** + * Creates a refinement function for validating that objects in an array have no duplicates + * based on specified keys + * + * @param arrayPath - The path to the array property in the context value (e.g., ['objects']). Use [] for direct array validation. + * @param keys - The keys to use for duplicate detection (e.g., ['id'] or ['source_name', 'external_id']). Use [] for primitive arrays. + * @param errorMessage - Optional custom error message template. Use {keys} for key values, {value} for primitives, and {index} for position + * @returns A refinement function for duplicate validation + * + * @remarks + * This function validates that objects in an array are unique based on one or more key fields. + * It creates a composite key from the specified fields and checks for duplicates. + * + * **Supports three validation modes:** + * 1. Object arrays with single key: `keys = ['id']` + * 2. Object arrays with composite keys: `keys = ['source_name', 'external_id']` + * 3. Primitive arrays: `keys = []` (validates the values themselves) + * + * @example + * ```typescript + * // Single key validation + * const validateUniqueIds = validateNoDuplicates(['objects'], ['id']); + * const schema = baseSchema.check(validateUniqueIds); + * + * // Composite key validation + * const validateUniqueRefs = validateNoDuplicates( + * ['external_references'], + * ['source_name', 'external_id'], + * 'Duplicate reference found with source_name="{source_name}" and external_id="{external_id}"' + * ); + * + * // Primitive array validation (e.g., array of strings) + * const validateUniqueStrings = validateNoDuplicates( + * [], + * [], + * 'Duplicate value "{value}" found' + * ); + * ``` + */ +export function validateNoDuplicates(arrayPath: string[], keys: string[], errorMessage?: string) { + return (ctx: z.core.ParsePayload): void => { + // Navigate to the array using the path + let arr: unknown = ctx.value; + for (const pathSegment of arrayPath) { + if (arr && typeof arr === 'object') { + arr = (arr as Record)[pathSegment]; + } else { + return; + } + } + + // If array doesn't exist or is not an array, skip validation + if (!Array.isArray(arr)) { + return; + } + + const seen = new Map(); + + arr.forEach((item, index) => { + // Create composite key from specified keys + // If keys array is empty, treat each item as a primitive value + const keyValues = + keys.length === 0 + ? [String(item)] + : keys.map((key) => { + const value = item?.[key]; + return value !== undefined ? String(value) : ''; + }); + const compositeKey = keyValues.join('||'); + + if (seen.has(compositeKey)) { + // Build key-value pairs for error message + const keyValuePairs = keys.reduce( + (acc, key, i) => { + acc[key] = keyValues[i]; + return acc; + }, + {} as Record, + ); + + // Generate error message + let message = errorMessage; + if (!message) { + if (keys.length === 0) { + // Primitive array (no keys) + message = `Duplicate value "${keyValues[0]}" found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`; + } else if (keys.length === 1) { + message = `Duplicate object with ${keys[0]}="${keyValues[0]}" found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`; + } else { + const keyPairs = keys.map((key, i) => `${key}="${keyValues[i]}"`).join(', '); + message = `Duplicate object with ${keyPairs} found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`; + } + } else { + // Replace placeholders in custom message + message = message.replace(/\{(\w+)\}/g, (match, key) => { + if (key === 'index') return String(index); + if (key === 'value' && keys.length === 0) return keyValues[0]; + return keyValuePairs[key] ?? match; + }); + } + + ctx.issues.push({ + code: 'custom', + message, + path: keys.length === 0 ? [...arrayPath, index] : [...arrayPath, index, ...keys], + input: keys.length === 0 ? item : keys.length === 1 ? item?.[keys[0]] : keyValuePairs, + }); + } else { + seen.set(compositeKey, index); + } + }); + }; +} + /** * Creates a refinement function for validating that all objects in a STIX bundle have unique IDs * + * @deprecated Use `validateNoDuplicates(['objects'], ['id'])` instead for more flexibility * @returns A refinement function for unique object ID validation * * @remarks * This function validates that each object in the bundle's 'objects' array has a unique 'id' property. * Duplicate IDs violate STIX specifications and can cause data integrity issues. * + * **Note:** This function is deprecated in favor of the more generic `validateNoDuplicates` function, + * which can validate uniqueness on any combination of keys, not just 'id'. + * * @example * ```typescript + * // Old way (deprecated) * const validateUniqueObjects = createUniqueObjectsOnlyRefinement(); * const schema = stixBundleSchema.check(validateUniqueObjects); + * + * // New way (recommended) + * const schema = stixBundleSchema.check(validateNoDuplicates(['objects'], ['id'])); * ``` */ export function createUniqueObjectsOnlyRefinement() { diff --git a/src/schemas/sdo/analytic.schema.ts b/src/schemas/sdo/analytic.schema.ts index ae87701d..af829c24 100644 --- a/src/schemas/sdo/analytic.schema.ts +++ b/src/schemas/sdo/analytic.schema.ts @@ -10,6 +10,7 @@ import { xMitreModifiedByRefSchema, xMitrePlatformsSchema, } from '../common/property-schemas/index.js'; +import { validateNoDuplicates } from '../../refinements/index.js'; //============================================================================== // @@ -46,28 +47,15 @@ export type LogSourceReference = z.infer; export const xMitreLogSourceReferencesSchema = z .array(xMitreLogSourceReferenceSchema) .min(1) - .refine( - // Reject duplicate log source references, delineated by (x_mitre_data_component_ref, name, channel) - // An analytic cannot reference the same log source twice - (logSourceReferences) => { - const seenRefs = new Set(); - - for (const logSourceRef of logSourceReferences) { - const key = `${logSourceRef.x_mitre_data_component_ref}|${logSourceRef.name}|${logSourceRef.channel}`; - if (seenRefs.has(key)) { - return false; - } - seenRefs.add(key); - } - - return true; - }, - { - message: - 'Duplicate log source reference found: each (x_mitre_data_component_ref, name, channel) tuple must be unique', - path: ['x_mitre_log_source_references'], - }, - ) + .check((ctx) => { + // Validate no duplicate log source references using composite key validation + // Each (x_mitre_data_component_ref, name, channel) tuple must be unique + validateNoDuplicates( + [], + ['x_mitre_data_component_ref', 'name', 'channel'], + 'Duplicate log source reference found: each (x_mitre_data_component_ref, name, channel) tuple must be unique', + )(ctx); + }) .meta({ description: 'A list of log source references, which are delineated by a Data Component STIX ID and the (`name`, `channel`) that is being targeted.', diff --git a/src/schemas/sdo/data-component.schema.ts b/src/schemas/sdo/data-component.schema.ts index b448a52e..9688ddab 100644 --- a/src/schemas/sdo/data-component.schema.ts +++ b/src/schemas/sdo/data-component.schema.ts @@ -10,6 +10,7 @@ import { xMitreDomainsSchema, xMitreModifiedByRefSchema, } from '../common/property-schemas/index.js'; +import { validateNoDuplicates } from '../../refinements/index.js'; //============================================================================== // @@ -46,28 +47,15 @@ export const xMitreLogSourcesSchema = z .strict(), ) .min(1) - .refine( - // Reject duplicate (name, channel) pairs - // Allow same name with different channels - // Allow same channel with different names - (permutations) => { - const seen = new Set(); - - for (const perm of permutations) { - const key = `${perm.name}|${perm.channel}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - } - - return true; - }, - { - message: 'Duplicate log source found: each (name, channel) pair must be unique', - path: ['x_mitre_log_sources'], - }, - ) + .check((ctx) => { + // Validate no duplicate (name, channel) pairs using composite key validation + // Allow same name with different channels, and same channel with different names + validateNoDuplicates( + [], + ['name', 'channel'], + 'Duplicate log source found: each (name, channel) pair must be unique', + )(ctx); + }) .meta({ description: ` The \`log_source\` object defines platform-specific collection configurations embedded within data components: diff --git a/src/schemas/sdo/detection-strategy.schema.ts b/src/schemas/sdo/detection-strategy.schema.ts index 9aa6bf6f..b54b2ede 100644 --- a/src/schemas/sdo/detection-strategy.schema.ts +++ b/src/schemas/sdo/detection-strategy.schema.ts @@ -8,6 +8,7 @@ import { xMitreDomainsSchema, xMitreModifiedByRefSchema, } from '../common/property-schemas/index.js'; +import { validateNoDuplicates } from '../../refinements/index.js'; //============================================================================== // @@ -31,19 +32,12 @@ export const detectionStrategySchema = attackBaseDomainObjectSchema .array(createStixIdValidator('x-mitre-analytic')) .nonempty({ error: 'At least one analytic ref is required' }) .check((ctx) => { - const seen = new Set(); - ctx.value.forEach((analyticId, index) => { - if (seen.has(analyticId)) { - ctx.issues.push({ - code: 'custom', - message: `Duplicate reference "${analyticId}" found. Each embedded relationship referenced in x_mitre_analytic_refs must be unique.`, - path: ['x_mitre_analytic_refs', index], - input: analyticId, - }); - } else { - seen.add(analyticId); - } - }); + // Validate no duplicate analytic references using primitive array validation + validateNoDuplicates( + [], + [], + 'Duplicate reference "{value}" found. Each embedded relationship referenced in x_mitre_analytic_refs must be unique.', + )(ctx); }) .meta({ description: diff --git a/test/refinements/validate-no-duplicates.test.ts b/test/refinements/validate-no-duplicates.test.ts new file mode 100644 index 00000000..8d9b4099 --- /dev/null +++ b/test/refinements/validate-no-duplicates.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { validateNoDuplicates } from '../../src/refinements/index.js'; + +/** + * Test suite for validateNoDuplicates refinement function + */ +describe('validateNoDuplicates', () => { + describe('Single key validation', () => { + it('should accept array with unique values for single key', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + name: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const validData = { + items: [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, + { id: '3', name: 'Item 3' }, + ], + }; + + expect(() => schema.parse(validData)).not.toThrow(); + }); + + it('should reject array with duplicate values for single key', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + name: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const invalidData = { + items: [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, + { id: '1', name: 'Item 3' }, // Duplicate id + ], + }; + + expect(() => schema.parse(invalidData)).toThrow(/Duplicate object/); + }); + + it('should include the duplicate value in error message for single key', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + name: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const invalidData = { + items: [ + { id: 'abc-123', name: 'Item 1' }, + { id: 'def-456', name: 'Item 2' }, + { id: 'abc-123', name: 'Item 3' }, // Duplicate + ], + }; + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw for duplicate values'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toContain('abc-123'); + expect(errorMessage).toContain('index 2'); + expect(errorMessage).toContain('index 0'); + } else { + throw error; + } + } + }); + }); + + describe('Composite key validation', () => { + it('should accept array with unique composite keys', () => { + const schema = z + .object({ + references: z.array( + z.object({ + source_name: z.string(), + external_id: z.string(), + url: z.string().optional(), + }), + ), + }) + .check(validateNoDuplicates(['references'], ['source_name', 'external_id'])); + + const validData = { + references: [ + { source_name: 'mitre-attack', external_id: 'T1001' }, + { source_name: 'mitre-attack', external_id: 'T1002' }, + { source_name: 'other-source', external_id: 'T1001' }, + ], + }; + + expect(() => schema.parse(validData)).not.toThrow(); + }); + + it('should reject array with duplicate composite keys', () => { + const schema = z + .object({ + references: z.array( + z.object({ + source_name: z.string(), + external_id: z.string(), + url: z.string().optional(), + }), + ), + }) + .check(validateNoDuplicates(['references'], ['source_name', 'external_id'])); + + const invalidData = { + references: [ + { source_name: 'mitre-attack', external_id: 'T1001' }, + { source_name: 'other-source', external_id: 'T1002' }, + { source_name: 'mitre-attack', external_id: 'T1001' }, // Duplicate composite key + ], + }; + + expect(() => schema.parse(invalidData)).toThrow(/Duplicate object/); + }); + + it('should include both key values in error message for composite keys', () => { + const schema = z + .object({ + references: z.array( + z.object({ + source_name: z.string(), + external_id: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['references'], ['source_name', 'external_id'])); + + const invalidData = { + references: [ + { source_name: 'source-a', external_id: 'id-1' }, + { source_name: 'source-b', external_id: 'id-2' }, + { source_name: 'source-a', external_id: 'id-1' }, // Duplicate + ], + }; + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw for duplicate composite keys'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toContain('source-a'); + expect(errorMessage).toContain('id-1'); + expect(errorMessage).toContain('index 2'); + expect(errorMessage).toContain('index 0'); + } else { + throw error; + } + } + }); + }); + + describe('Custom error messages', () => { + it('should use custom error message when provided', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + name: z.string(), + }), + ), + }) + .check( + validateNoDuplicates( + ['items'], + ['id'], + 'Found duplicate item with ID {id} at position {index}', + ), + ); + + const invalidData = { + items: [ + { id: 'item-1', name: 'First' }, + { id: 'item-2', name: 'Second' }, + { id: 'item-1', name: 'Third' }, // Duplicate + ], + }; + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw with custom error message'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toBe('Found duplicate item with ID item-1 at position 2'); + } else { + throw error; + } + } + }); + + it('should support placeholders for composite keys in custom messages', () => { + const schema = z + .object({ + refs: z.array( + z.object({ + source: z.string(), + id: z.string(), + }), + ), + }) + .check( + validateNoDuplicates( + ['refs'], + ['source', 'id'], + 'Duplicate reference: {source}/{id} at index {index}', + ), + ); + + const invalidData = { + refs: [ + { source: 'github', id: '123' }, + { source: 'gitlab', id: '456' }, + { source: 'github', id: '123' }, // Duplicate + ], + }; + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw with custom error message'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toBe('Duplicate reference: github/123 at index 2'); + } else { + throw error; + } + } + }); + }); + + describe('Edge cases', () => { + it('should handle empty arrays', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const validData = { + items: [], + }; + + expect(() => schema.parse(validData)).not.toThrow(); + }); + + it('should handle arrays with a single item', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const validData = { + items: [{ id: '1' }], + }; + + expect(() => schema.parse(validData)).not.toThrow(); + }); + + it('should handle missing array gracefully', () => { + const schema = z + .object({ + items: z + .array( + z.object({ + id: z.string(), + }), + ) + .optional(), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const validData = {}; + + expect(() => schema.parse(validData)).not.toThrow(); + }); + + it('should handle undefined key values', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string().optional(), + name: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const validData = { + items: [ + { name: 'Item 1' }, // No id + { id: '1', name: 'Item 2' }, + { name: 'Item 3' }, // No id - should be considered duplicate of first + ], + }; + + // Items with undefined id should still be checked for duplicates + expect(() => schema.parse(validData)).toThrow(/Duplicate object/); + }); + + it('should handle nested array paths', () => { + const schema = z + .object({ + bundle: z.object({ + objects: z.array( + z.object({ + id: z.string(), + type: z.string(), + }), + ), + }), + }) + .check(validateNoDuplicates(['bundle', 'objects'], ['id'])); + + const invalidData = { + bundle: { + objects: [ + { id: 'obj-1', type: 'type-a' }, + { id: 'obj-2', type: 'type-b' }, + { id: 'obj-1', type: 'type-c' }, // Duplicate + ], + }, + }; + + expect(() => schema.parse(invalidData)).toThrow(/Duplicate object/); + }); + }); + + describe('Multiple duplicates', () => { + it('should report all duplicates in array', () => { + const schema = z + .object({ + items: z.array( + z.object({ + id: z.string(), + }), + ), + }) + .check(validateNoDuplicates(['items'], ['id'])); + + const invalidData = { + items: [ + { id: '1' }, + { id: '2' }, + { id: '1' }, // First duplicate + { id: '3' }, + { id: '2' }, // Second duplicate + { id: '1' }, // Third duplicate + ], + }; + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw for multiple duplicates'); + } catch (error) { + if (error instanceof z.ZodError) { + // Should have 3 errors (indices 2, 4, 5) + expect(error.issues.length).toBe(3); + } else { + throw error; + } + } + }); + }); + + describe('Primitive array validation', () => { + it('should accept array with unique primitive values', () => { + const schema = z.array(z.string()).check(validateNoDuplicates([], [])); + + const validData = ['value1', 'value2', 'value3']; + + expect(() => schema.parse(validData)).not.toThrow(); + }); + + it('should reject array with duplicate primitive values', () => { + const schema = z.array(z.string()).check(validateNoDuplicates([], [])); + + const invalidData = ['value1', 'value2', 'value1']; // Duplicate + + expect(() => schema.parse(invalidData)).toThrow(/Duplicate value/); + }); + + it('should include the duplicate value in error message for primitives', () => { + const schema = z.array(z.string()).check(validateNoDuplicates([], [])); + + const invalidData = ['abc', 'def', 'abc']; // Duplicate + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw for duplicate primitives'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toContain('abc'); + expect(errorMessage).toContain('index 2'); + expect(errorMessage).toContain('index 0'); + } else { + throw error; + } + } + }); + + it('should use custom error message for primitive arrays', () => { + const schema = z + .array(z.string()) + .check(validateNoDuplicates([], [], 'Duplicate ID "{value}" at position {index}')); + + const invalidData = ['id-1', 'id-2', 'id-1']; // Duplicate + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw with custom error message'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toBe('Duplicate ID "id-1" at position 2'); + } else { + throw error; + } + } + }); + + it('should handle multiple duplicates in primitive array', () => { + const schema = z.array(z.string()).check(validateNoDuplicates([], [])); + + const invalidData = ['a', 'b', 'a', 'c', 'b', 'a']; + + try { + schema.parse(invalidData); + expect.fail('Expected schema to throw for multiple duplicates'); + } catch (error) { + if (error instanceof z.ZodError) { + // Should have 3 errors (indices 2, 4, 5) + expect(error.issues.length).toBe(3); + } else { + throw error; + } + } + }); + + it('should work with number arrays', () => { + const schema = z.array(z.number()).check(validateNoDuplicates([], [])); + + const invalidData = [1, 2, 3, 2]; // Duplicate + + expect(() => schema.parse(invalidData)).toThrow(/Duplicate value/); + }); + }); +}); From 9c6fc9a05d5e4e81cd1f1dbe787483bd05acaf95 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:49:07 -0500 Subject: [PATCH 5/6] feat(validation): add validateXMitreContentsReferences refinement for STIX bundle integrity Add a new validateXMitreContentsReferences refinement factory function that validates all STIX IDs referenced in a collection's x_mitre_contents property have corresponding objects in the bundle's objects array. This ensures referential integrity within STIX bundles. Replace the deprecated createUniqueObjectsOnlyRefinement with the more flexible validateNoDuplicates refinement in stix-bundle schema for improved consistency. Add comprehensive test coverage with 5 tests validating: - Valid references pass validation - Missing references are rejected with appropriate error messages - Multiple missing references are all reported - Multiple valid references are accepted Fix test setup to reference the collection's own ID instead of a non-existent object in the minimal test fixture. --- src/refinements/index.ts | 48 +++++++ src/schemas/sdo/stix-bundle.schema.ts | 15 +- test/objects/stix-bundle.test.ts | 200 +++++++++++++++++++++++++- 3 files changed, 259 insertions(+), 4 deletions(-) diff --git a/src/refinements/index.ts b/src/refinements/index.ts index b6322836..864bf9f1 100644 --- a/src/refinements/index.ts +++ b/src/refinements/index.ts @@ -5,6 +5,7 @@ import { attackIdPatterns, type Aliases, type AttackObject, + type Collection, type ExternalReferences, type KillChainPhase, type StixBundle, @@ -304,6 +305,53 @@ export function validateNoDuplicates(arrayPath: string[], keys: string[], errorM }; } +/** + * Creates a refinement function for validating that all STIX IDs referenced in x_mitre_contents + * exist in the bundle's objects array + * + * @returns A refinement function for x_mitre_contents reference validation + * + * @remarks + * This function validates that every STIX ID referenced in the collection's x_mitre_contents + * property (which acts as a table of contents for the bundle) has a corresponding object + * in the bundle's objects array. This ensures referential integrity within the bundle. + * + * The function expects: + * - The first object in the bundle to be a Collection (x-mitre-collection type) + * - Each object_ref in x_mitre_contents to match an id in the objects array + * + * @example + * ```typescript + * const schema = stixBundleSchema.check(validateXMitreContentsReferences()); + * ``` + */ +export function validateXMitreContentsReferences() { + return (ctx: z.core.ParsePayload): void => { + // Get the collection object (first object in bundle) + const collectionObject = ctx.value.objects[0]; + const collectionContents = (collectionObject as Collection).x_mitre_contents; + + if (!collectionContents) { + return; + } + + // Create a set of all object IDs in the bundle for efficient lookup + const objectIds = new Set(ctx.value.objects.map((obj) => (obj as AttackObject).id)); + + // Validate each reference in x_mitre_contents + collectionContents.forEach((contentRef: { object_ref: string }, index: number) => { + if (!objectIds.has(contentRef.object_ref)) { + ctx.issues.push({ + code: 'custom', + message: `STIX ID "${contentRef.object_ref}" referenced in x_mitre_contents is not present in the bundle's objects array`, + path: ['objects', 0, 'x_mitre_contents', index, 'object_ref'], + input: contentRef.object_ref, + }); + } + }); + }; +} + /** * Creates a refinement function for validating that all objects in a STIX bundle have unique IDs * diff --git a/src/schemas/sdo/stix-bundle.schema.ts b/src/schemas/sdo/stix-bundle.schema.ts index e006f073..941e1f9c 100644 --- a/src/schemas/sdo/stix-bundle.schema.ts +++ b/src/schemas/sdo/stix-bundle.schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod/v4'; import { createFirstBundleObjectRefinement, - createUniqueObjectsOnlyRefinement, + validateNoDuplicates, + validateXMitreContentsReferences, } from '../../refinements/index.js'; import { createStixIdValidator, @@ -191,8 +192,18 @@ export const stixBundleSchema = z }) .strict() .check((ctx) => { + // Validate that the first object in the 'objects' array is of type 'x-mitre-collection' createFirstBundleObjectRefinement()(ctx); - createUniqueObjectsOnlyRefinement()(ctx); + + // Validate that all IDs referenced in 'x_mitre_contents' are present in 'objects' array + validateXMitreContentsReferences()(ctx); + + // Validate that no duplicate objects are present in 'objects' array + validateNoDuplicates( + ['objects'], + ['id'], + 'Duplicate object with id "{id}" found. Each object in the bundle must have a unique id.', + )(ctx); }); export type StixBundle = z.infer; diff --git a/test/objects/stix-bundle.test.ts b/test/objects/stix-bundle.test.ts index e14a41dd..5d8396b8 100644 --- a/test/objects/stix-bundle.test.ts +++ b/test/objects/stix-bundle.test.ts @@ -19,8 +19,9 @@ describe('StixBundleSchema', () => { let minimalCollection: Collection; beforeEach(() => { + const collectionId = `x-mitre-collection--${uuidv4()}`; minimalCollection = { - id: `x-mitre-collection--${uuidv4()}`, + id: collectionId, type: 'x-mitre-collection', spec_version: '2.1', created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', @@ -33,7 +34,7 @@ describe('StixBundleSchema', () => { x_mitre_version: '1.0', x_mitre_contents: [ { - object_ref: 'attack-pattern--0042a9f5-f053-4769-b3ef-9ad018dfa298', + object_ref: collectionId, object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, }, ], @@ -428,6 +429,201 @@ describe('StixBundleSchema', () => { } }); }); + + describe('x_mitre_contents Validation', () => { + it('should accept bundle where all x_mitre_contents references exist in objects (true positive)', () => { + const techniqueId = `attack-pattern--${uuidv4()}`; + const technique: Technique = { + id: techniqueId, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1234', + }, + ], + }; + + const collectionWithValidRef: Collection = { + ...minimalCollection, + x_mitre_contents: [ + { + object_ref: techniqueId, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + ], + }; + + const bundleWithValidRefs = { + ...minimalBundle, + objects: [collectionWithValidRef, technique], + }; + + expect(() => stixBundleSchema.parse(bundleWithValidRefs)).not.toThrow(); + }); + + it('should reject bundle where x_mitre_contents references a missing object (true negative)', () => { + const missingId = `attack-pattern--${uuidv4()}`; + + const collectionWithInvalidRef: Collection = { + ...minimalCollection, + x_mitre_contents: [ + { + object_ref: missingId, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + ], + }; + + const bundleWithMissingRef = { + ...minimalBundle, + objects: [collectionWithInvalidRef], + }; + + expect(() => stixBundleSchema.parse(bundleWithMissingRef)).toThrow( + /referenced in x_mitre_contents is not present in the bundle's objects array/, + ); + }); + + it('should report the missing STIX ID in error message', () => { + const missingId = `attack-pattern--${uuidv4()}`; + + const collectionWithInvalidRef: Collection = { + ...minimalCollection, + x_mitre_contents: [ + { + object_ref: missingId, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + ], + }; + + const bundleWithMissingRef = { + ...minimalBundle, + objects: [collectionWithInvalidRef], + }; + + try { + stixBundleSchema.parse(bundleWithMissingRef); + expect.fail('Expected schema to throw for missing x_mitre_contents reference'); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.issues[0].message; + expect(errorMessage).toContain(missingId); + } else { + throw error; + } + } + }); + + it('should handle multiple missing references in x_mitre_contents', () => { + const missingId1 = `attack-pattern--${uuidv4()}`; + const missingId2 = `attack-pattern--${uuidv4()}`; + + const collectionWithMultipleMissingRefs: Collection = { + ...minimalCollection, + x_mitre_contents: [ + { + object_ref: missingId1, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + { + object_ref: missingId2, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + ], + }; + + const bundleWithMultipleMissingRefs = { + ...minimalBundle, + objects: [collectionWithMultipleMissingRefs], + }; + + try { + stixBundleSchema.parse(bundleWithMultipleMissingRefs); + expect.fail('Expected schema to throw for multiple missing x_mitre_contents references'); + } catch (error) { + if (error instanceof z.ZodError) { + // Should have 2 errors (one for each missing reference) + expect(error.issues.length).toBe(2); + } else { + throw error; + } + } + }); + + it('should accept bundle with mix of valid and present references in x_mitre_contents', () => { + const techniqueId1 = `attack-pattern--${uuidv4()}`; + const techniqueId2 = `attack-pattern--${uuidv4()}`; + + const technique1: Technique = { + id: techniqueId1, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 1', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1001', + }, + ], + }; + + const technique2: Technique = { + id: techniqueId2, + type: 'attack-pattern', + spec_version: '2.1', + created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp, + modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + name: 'Test Technique 2', + x_mitre_attack_spec_version: '2.1.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T1002', + }, + ], + }; + + const collectionWithMultipleValidRefs: Collection = { + ...minimalCollection, + x_mitre_contents: [ + { + object_ref: techniqueId1, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + { + object_ref: techniqueId2, + object_modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp, + }, + ], + }; + + const bundleWithMultipleValidRefs = { + ...minimalBundle, + objects: [collectionWithMultipleValidRefs, technique1, technique2], + }; + + expect(() => stixBundleSchema.parse(bundleWithMultipleValidRefs)).not.toThrow(); + }); + }); }); // GitHub Actions often fails without an increased timeout for this test From 21d054c8bb0408968706cb4ea2dc88e430a83506 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:50:59 -0500 Subject: [PATCH 6/6] revert: remove unused refinement factory function, createUniqueObjectsOnlyRefinement --- src/refinements/index.ts | 42 ---------------------------------------- 1 file changed, 42 deletions(-) diff --git a/src/refinements/index.ts b/src/refinements/index.ts index 864bf9f1..5d3a3347 100644 --- a/src/refinements/index.ts +++ b/src/refinements/index.ts @@ -352,48 +352,6 @@ export function validateXMitreContentsReferences() { }; } -/** - * Creates a refinement function for validating that all objects in a STIX bundle have unique IDs - * - * @deprecated Use `validateNoDuplicates(['objects'], ['id'])` instead for more flexibility - * @returns A refinement function for unique object ID validation - * - * @remarks - * This function validates that each object in the bundle's 'objects' array has a unique 'id' property. - * Duplicate IDs violate STIX specifications and can cause data integrity issues. - * - * **Note:** This function is deprecated in favor of the more generic `validateNoDuplicates` function, - * which can validate uniqueness on any combination of keys, not just 'id'. - * - * @example - * ```typescript - * // Old way (deprecated) - * const validateUniqueObjects = createUniqueObjectsOnlyRefinement(); - * const schema = stixBundleSchema.check(validateUniqueObjects); - * - * // New way (recommended) - * const schema = stixBundleSchema.check(validateNoDuplicates(['objects'], ['id'])); - * ``` - */ -export function createUniqueObjectsOnlyRefinement() { - return (ctx: z.core.ParsePayload): void => { - const seen = new Set(); - ctx.value.objects.forEach((item, index) => { - const id = (item as AttackObject).id; - if (seen.has(id)) { - ctx.issues.push({ - code: 'custom', - message: `Duplicate object with id "${id}" found. Each object in the bundle must have a unique id.`, - path: ['objects', index, 'id'], - input: id, - }); - } else { - seen.add(id); - } - }); - }; -} - /** * Creates a refinement function for validating ATT&CK ID in external references *