From c9d1f61c2cf2090dc0895bfeabb5dfbacd9f2a8e Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:27:18 -0500 Subject: [PATCH] fix: add duplicate detection for reference list properties - Add uniqueArray() utility function to validate array uniqueness with detailed error messages - Apply uniqueness validation to all _refs properties (object_marking_refs, x_mitre_tactic_refs, x_mitre_analytic_refs) - Update detection-strategy test expectations to align with new duplicate prevention behavior - Add support for new ATT&CK v18 SDO types (x-mitre-analytic, x-mitre-detection-strategy) in test data validation The uniqueArray() function uses Zod v4's check() method to report which specific values are duplicated, improving developer experience when validation fails. Reference list properties now properly enforce uniqueness constraints, preventing duplicate embedded relationships in STIX objects. --- .../common/property-schemas/generics.ts | 45 +++++++++++++++++++ .../property-schemas/stix-attribution.ts | 15 +++---- src/schemas/sdo/detection-strategy.schema.ts | 7 +-- src/schemas/sdo/matrix.schema.ts | 4 +- test/objects/detection-strategy.test.ts | 4 +- test/utils/attack-data.test.ts | 6 ++- 6 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/schemas/common/property-schemas/generics.ts b/src/schemas/common/property-schemas/generics.ts index f44c4158..c89995b5 100644 --- a/src/schemas/common/property-schemas/generics.ts +++ b/src/schemas/common/property-schemas/generics.ts @@ -44,3 +44,48 @@ export const stixListOfString = z.array(nonEmptyRequiredString).min(1, { error: 'Empty lists are prohibited in STIX and MUST NOT be used as a substitute for omitting the property if it is optional. The list MUST be present and MUST have at least one value.', }); + +/** + * A utility function that creates a Zod array schema with a uniqueness constraint. + * + * This function wraps any Zod schema in an array validator that ensures all items + * are unique (no duplicate values). Uniqueness is determined by JavaScript's Set + * equality, which uses SameValueZero comparison. + * + * @param schema - The Zod schema type for individual array elements + * @returns A Zod array schema that validates element uniqueness + * + * @example + * ```typescript + * const uniqueStrings = uniqueArray(z.string()); + * uniqueStrings.parse(["a", "b", "c"]); // ["a", "b", "c"] + * uniqueStrings.parse(["a", "b", "a"]); // throws error + * + * const uniqueNumbers = uniqueArray(z.number()); + * uniqueNumbers.parse([1, 2, 3]); // [1, 2, 3] + * uniqueNumbers.parse([1, 2, 2]); // throws error + * ``` + */ +export function uniqueArray(schema: T) { + return z.array(schema).check((ctx) => { + const seen = new Map, number>(); + const duplicates: z.infer[] = []; + + ctx.value.forEach((item, index) => { + if (seen.has(item)) { + duplicates.push(item); + } else { + seen.set(item, index); + } + }); + + if (duplicates.length > 0) { + ctx.issues.push({ + code: 'custom', + message: `Duplicate values found: ${duplicates.map((d) => JSON.stringify(d)).join(', ')}`, + // path: [], // not sure how to dynamically determine key name + input: ctx.value, + }); + } + }); +} diff --git a/src/schemas/common/property-schemas/stix-attribution.ts b/src/schemas/common/property-schemas/stix-attribution.ts index 0fd8ac5f..3ab3ed01 100644 --- a/src/schemas/common/property-schemas/stix-attribution.ts +++ b/src/schemas/common/property-schemas/stix-attribution.ts @@ -1,4 +1,5 @@ import { z } from 'zod/v4'; +import { uniqueArray } from './generics.js'; import { createStixIdValidator, stixIdentifierSchema } from './stix-id.js'; /** @@ -15,14 +16,12 @@ import { createStixIdValidator, stixIdentifierSchema } from './stix-id.js'; * objectMarkingRefsSchema.parse(['identity--12345']); // Invalid - must be marking-definition * ``` */ -export const objectMarkingRefsSchema = z - .array( - stixIdentifierSchema.startsWith( - 'marking-definition--', - 'Identifier must start with "marking-definition--"', - ), - ) - .meta({ description: 'The list of marking-definition objects to be applied to this object.' }); +export const objectMarkingRefsSchema = uniqueArray( + stixIdentifierSchema.startsWith( + 'marking-definition--', + 'Identifier must start with "marking-definition--"', + ), +).meta({ description: 'The list of marking-definition objects to be applied to this object.' }); // TODO add JSDoc export type ObjectMarkingRefs = z.infer; diff --git a/src/schemas/sdo/detection-strategy.schema.ts b/src/schemas/sdo/detection-strategy.schema.ts index e56b9804..158e454d 100644 --- a/src/schemas/sdo/detection-strategy.schema.ts +++ b/src/schemas/sdo/detection-strategy.schema.ts @@ -4,6 +4,7 @@ import { createAttackExternalReferencesSchema, createStixIdValidator, createStixTypeValidator, + uniqueArray, xMitreContributorsSchema, xMitreDomainsSchema, xMitreModifiedByRefSchema, @@ -27,9 +28,9 @@ export const detectionStrategySchema = attackBaseDomainObjectSchema x_mitre_contributors: xMitreContributorsSchema, - x_mitre_analytic_refs: z - .array(createStixIdValidator('x-mitre-analytic')) - .min(1, { error: 'At least one analytic ref is required' }), + x_mitre_analytic_refs: uniqueArray(createStixIdValidator('x-mitre-analytic')).min(1, { + error: 'At least one analytic ref is required', + }), x_mitre_domains: xMitreDomainsSchema, }) diff --git a/src/schemas/sdo/matrix.schema.ts b/src/schemas/sdo/matrix.schema.ts index 6ae5b761..f6179bdb 100644 --- a/src/schemas/sdo/matrix.schema.ts +++ b/src/schemas/sdo/matrix.schema.ts @@ -4,6 +4,7 @@ import { createStixIdValidator, createStixTypeValidator, descriptionSchema, + uniqueArray, xMitreDomainsSchema, xMitreModifiedByRefSchema, } from '../common/property-schemas/index.js'; @@ -15,8 +16,7 @@ import { // //============================================================================== -export const xMitreTacticRefsSchema = z - .array(createStixIdValidator('x-mitre-tactic')) +export const xMitreTacticRefsSchema = uniqueArray(createStixIdValidator('x-mitre-tactic')) .min(1, { error: 'At least one tactic ref is required' }) .meta({ description: diff --git a/test/objects/detection-strategy.test.ts b/test/objects/detection-strategy.test.ts index 17b60e19..dc261513 100644 --- a/test/objects/detection-strategy.test.ts +++ b/test/objects/detection-strategy.test.ts @@ -232,8 +232,8 @@ describe('detectionStrategySchema', () => { ...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 throw + expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).toThrow(); }); it('should handle large number of analytics', () => { diff --git a/test/utils/attack-data.test.ts b/test/utils/attack-data.test.ts index 0b66cd8c..8105ae2c 100644 --- a/test/utils/attack-data.test.ts +++ b/test/utils/attack-data.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; describe('Global Tests', () => { describe('ATT&CK Testing Data', () => { @@ -43,6 +43,8 @@ describe('Global Tests', () => { 'x-mitre-matrix', 'x-mitre-tactic', 'x-mitre-asset', + 'x-mitre-analytic', + 'x-mitre-detection-strategy' ]; const presentTypes = Object.keys(globalThis.attackData.objectsByType); @@ -129,6 +131,8 @@ describe('Global Tests', () => { 'x-mitre-data-source', 'x-mitre-data-component', 'x-mitre-asset', + 'x-mitre-analytic', + 'x-mitre-detection-strategy' ]; const sdoCount = sdoTypes.reduce( (sum, type) => sum + (globalThis.attackData.objectsByType[type]?.length || 0),