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/3] 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/3] 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/3] 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', () => {