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),