Skip to content

Commit

Permalink
[backend] validate elUpdateElement input value against schema before …
Browse files Browse the repository at this point in the history
…indexing (#5696)

Co-authored-by: Landry Trebon <landry.trebon@filigran.io>
  • Loading branch information
labo-flg and lndrtrbn authored Mar 11, 2024
1 parent c8dd164 commit 7f524f6
Show file tree
Hide file tree
Showing 12 changed files with 359 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7549,8 +7549,8 @@ type StixCoreObjectEditMutations {

input StixDomainObjectFileEditInput {
id: String!
order: Int
description: String
order: Int
inCarousel: Boolean
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12045,8 +12045,8 @@ type StixCoreObjectEditMutations {

input StixDomainObjectFileEditInput {
id: String!
order: Int
description: String
order: Int
inCarousel: Boolean
}

Expand Down
5 changes: 4 additions & 1 deletion opencti-platform/opencti-graphql/src/database/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ import {
isNumericAttribute,
isObjectAttribute,
isObjectFlatAttribute,
schemaAttributesDefinition
schemaAttributesDefinition,
validateDataBeforeIndexing
} from '../schema/schema-attributes';
import { convertTypeToStixType } from './stix-converter';
import { extractEntityRepresentativeName } from './entity-representative';
Expand Down Expand Up @@ -3059,6 +3060,7 @@ export const elDeleteElements = async (context, user, elements) => {
await elDeleteInstances(elements);
};

// TODO: get rid of this function and let elastic fail queries, so we can fix all of them by using the right type of data
export const prepareElementForIndexing = (element) => {
const thing = {};
Object.keys(element).forEach((key) => {
Expand Down Expand Up @@ -3320,6 +3322,7 @@ const elUpdateConnectionsOfElement = async (documentId, documentBody) => {
};
export const elUpdateElement = async (instance) => {
const esData = prepareElementForIndexing(instance);
validateDataBeforeIndexing(esData);
const dataToReplace = R.dissoc('representative', esData);
const replacePromise = elReplace(instance._index, instance.internal_id, { doc: dataToReplace });
// If entity with a name, must update connections
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ export const stixCoreObjectImportPush = async (context, user, id, file, args = {
await elUpdateElement({
_index: previous._index,
internal_id: internalId,
entity_type: previous.entity_type, // required for schema validation
updated_at: now(),
x_opencti_files: files
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const stixDomainObjectsDistributionByEntity = async (context, user, args)

export const stixDomainObjectAvatar = (stixDomainObject) => {
const files = stixDomainObject.x_opencti_files ?? [];
return files.sort((a, b) => (a.order || 0) - (b.order || 0)).find((n) => n.mime_type.includes('image/') && n.inCarousel);
return files.sort((a, b) => (a.order || 0) - (b.order || 0)).find((n) => n.mime_type.includes('image/') && !!n.inCarousel);
};
// endregion

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ const attributes: Array<AttributeDefinition> = [
{ name: 'external_reference_id', label: 'Related external reference', type: 'string', format: 'short', mandatoryType: 'internal', editDefault: false, multiple: false, upsert: false, isFilterable: true },
{ name: 'messages', label: 'File messages', type: 'object', format: 'flat', mandatoryType: 'internal', editDefault: false, multiple: true, upsert: false, isFilterable: false },
{ name: 'errors', label: 'File errors', type: 'object', format: 'flat', mandatoryType: 'internal', editDefault: false, multiple: true, upsert: false, isFilterable: false },
{ name: 'inCarousel', label: 'Include in carousel', type: 'boolean', mandatoryType: 'internal', editDefault: false, multiple: false, upsert: false, isFilterable: true },
{ name: 'order', label: 'Carousel order', type: 'numeric', precision: 'integer', mandatoryType: 'internal', editDefault: false, multiple: false, upsert: false, isFilterable: true },
{ name: 'inCarousel', label: 'Include in carousel', type: 'boolean', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true },
{ name: 'order', label: 'Carousel order', type: 'numeric', precision: 'integer', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true },
]
},
// TODO MOVE THAT PART TO A SPECIFIC Place
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,10 @@ export const files: AttributeDefinition = {
id,
{ name: 'name', label: 'Name', type: 'string', format: 'short', editDefault: false, mandatoryType: 'no', multiple: true, upsert: true, isFilterable: true },
{ name: 'description', label: 'Name', type: 'string', format: 'text', editDefault: false, mandatoryType: 'no', multiple: true, upsert: true, isFilterable: true },
{ name: 'order', label: 'Order in carousel', type: 'numeric', precision: 'integer', editDefault: false, multiple: false, mandatoryType: 'external', upsert: true, isFilterable: true },
{ name: 'version', label: 'Version', type: 'date', editDefault: false, mandatoryType: 'no', multiple: true, upsert: true, isFilterable: true },
{ name: 'mime_type', label: 'Mime type', type: 'string', format: 'short', editDefault: false, mandatoryType: 'no', multiple: true, upsert: true, isFilterable: true },
{ name: 'inCarousel', label: 'Include in carousel', type: 'boolean', mandatoryType: 'internal', editDefault: false, multiple: false, upsert: true, isFilterable: true },
{ name: 'inCarousel', label: 'Include in carousel', type: 'boolean', mandatoryType: 'no', editDefault: false, multiple: false, upsert: true, isFilterable: true },
{ name: 'order', label: 'Order in carousel', type: 'numeric', precision: 'integer', mandatoryType: 'no', editDefault: false, multiple: false, upsert: true, isFilterable: true },
]
};

Expand Down
88 changes: 82 additions & 6 deletions opencti-platform/opencti-graphql/src/schema/schema-attributes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as R from 'ramda';
import { RULE_PREFIX } from './general';
import { UnsupportedError } from '../config/errors';
import type { AttributeDefinition, AttrType } from './attribute-definition';
import { FunctionalError, UnsupportedError } from '../config/errors';
import type { AttributeDefinition, AttrType, ComplexAttributeWithMappings } from './attribute-definition';
import { shortStringFormats } from './attribute-definition';
import { getParentTypes } from './schemaUtils';

Expand Down Expand Up @@ -197,16 +197,15 @@ export const isBooleanAttribute = (k: string): boolean => (
export const isDateAttribute = (k: string): boolean => (
schemaAttributesDefinition.isSpecificTypeAttribute(k, 'date')
);
export const isObjectAttribute = (k: string): boolean => (
schemaAttributesDefinition.isSpecificTypeAttribute(k, 'object')
);
export const isNumericAttribute = (k: string): boolean => (
schemaAttributesDefinition.isSpecificTypeAttribute(k, 'numeric')
);
export const isDateNumericOrBooleanAttribute = (k: string): boolean => (
schemaAttributesDefinition.isSpecificTypeAttribute(k, 'date', 'numeric', 'boolean')
);

export const isObjectAttribute = (k: string): boolean => (
schemaAttributesDefinition.isSpecificTypeAttribute(k, 'object')
);
export const isObjectFlatAttribute = (k: string): boolean => {
const definition = schemaAttributesDefinition.getAttributeByName(k.split('.')[0]);
if (!definition) return false;
Expand All @@ -218,3 +217,80 @@ export const isObjectFlatAttribute = (k: string): boolean => {
export const isMultipleAttribute = (entityType: string, k: string): boolean => (
k.startsWith(RULE_PREFIX) || schemaAttributesDefinition.isMultipleAttribute(entityType, k)
);

// -- utility functions independent of attribute registration --
// (inner mappings are not registered like first-level attribute)

export const isMandatoryAttributeMapping = (schemaDef: AttributeDefinition) => schemaDef.mandatoryType === 'external' || schemaDef.mandatoryType === 'internal';

export const isNonFlatObjectAttributeMapping = (schemaDef: AttributeDefinition) : schemaDef is ComplexAttributeWithMappings => { // handy typeguard
return schemaDef.type === 'object' && schemaDef.format !== 'flat';
};

/**
* Validates that the given input conforms to the constraints in the corresponding schema definition.
* Recursively checks non-flat objects mappings.
* @param input an input object candidate to indexing in elastic
* @param schemaDef AttributeDefinition for the given input data
*/
const validateInputAgainstSchema = (input: any, schemaDef: AttributeDefinition) => {
const isMandatory = isMandatoryAttributeMapping(schemaDef);
if (isMandatory && R.isNil(input)) {
throw FunctionalError(`Validation against schema failed on attribute [${schemaDef.name}]: this mandatory field cannot be nil`, { value: input });
}

if (isNonFlatObjectAttributeMapping(schemaDef)) {
if (!isMandatory && R.isNil(input)) {
return; // nothing to check (happens on 'remove' operation for instance
}
// check 'multiple' constraint
if (schemaDef.multiple && !Array.isArray(input)) {
throw FunctionalError(`Validation against schema failed on attribute [${schemaDef.name}]: value must be an array`, { value: input });
}
if (!schemaDef.multiple && (Array.isArray(input) || !R.is(Object, input))) {
throw FunctionalError(`Validation against schema failed on attribute [${schemaDef.name}]: value must be an object`, { value: input });
}

const inputValues = Array.isArray(input) ? input : [input];
inputValues.forEach((value) => {
// check the value adhere to its mapping
const valueKeys = Object.keys(value);
schemaDef.mappings.forEach((mapping) => {
// mandatory fields: the value must have a field with this name
if (isMandatoryAttributeMapping(mapping) && !valueKeys.includes(mapping.name)) {
throw FunctionalError(`Validation against schema failed on attribute [${schemaDef.name}]: mandatory field [${mapping.name}] is not present`, { value });
}
// ...we might add more constraints such as a numeric range.

// finally, recursively check mappings if any
const innerValue = value[mapping.name];
validateInputAgainstSchema(innerValue, mapping);
});
});
}
};

export const validateDataBeforeIndexing = (element: any) => {
if (!element.entity_type) {
throw FunctionalError('Validation against schema failed: element has no entity_type', { value: element });
}

// just check the given entity_type is in schema ; this call would throw a DatabaseError
try {
schemaAttributesDefinition.getAttributes(element.entity_type);
} catch (e: any) {
if (e.name === 'DATABASE_ERROR') {
throw FunctionalError('Validation against schema failed: this entity_type is not supported', { value: element });
}
throw e;
}

Object.keys(element).forEach((elementKey) => {
const input = element[elementKey];
const attributeSchemaDef = schemaAttributesDefinition.getAttributeByName(elementKey);
if (!attributeSchemaDef) {
return; // no validation to do, happens for meta fields like "_index"
}
validateInputAgainstSchema(input, attributeSchemaDef);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ export const validateAndFormatSchemaAttribute = (
if (!attributeDefinition || isEmptyField(editInput.value)) {
return;
}
if (!attributeDefinition.multiple && editInput.value.length > 1) {
const isPatchObject = attributeDefinition.type === 'object' && !!editInput.object_path;
if (!isPatchObject && !attributeDefinition.multiple && editInput.value.length > 1) {
// with a patch object, the value matches something inside the object and not the object itself
// so we cannot check directly the multiplicity as it concerns an internal mapping
// let's assume it's OK, and validateDataBeforeIndexing would check it.
throw ValidationError(attributeName, { message: `Attribute ${attributeName} cannot be multiple`, data: editInput });
}
// Data validation
Expand Down Expand Up @@ -79,9 +83,6 @@ export const validateAndFormatSchemaAttribute = (
}
});
}
if (attributeDefinition.type === 'object') {
// TODO JRI Implements a checker
}
};

const validateFormatSchemaAttributes = async (context: AuthContext, user: AuthUser, instanceType: string, editInputs: EditInput[]) => {
Expand Down
4 changes: 2 additions & 2 deletions opencti-platform/opencti-graphql/src/types/store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ interface StoreFile {
version: string;
mime_type: string;
description: string;
order: number;
inCarousel: boolean;
order?: number;
inCarousel?: boolean;
}

interface BasicStoreIdentifier {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { validateDataBeforeIndexing } from '../../../src/schema/schema-attributes';

describe('validateDataBeforeIndexing', () => {
const malware = {
_index: 'opencti_stix_domain_objects-000001',
internal_id: '3f9d5688-25e1-427f-8eff-a92110f87ca3',
entity_type: 'Malware',
representative: {
main: 'Agent Racoon'
},
is_family: true,
updated_at: '2024-02-23T09:43:39.913Z',
modified: '2024-02-23T09:43:39.913Z'
};

const threatActorIndividual = {
_index: 'opencti_stix_domain_objects-000001',
internal_id: 'ba7b01e7-78f9-45da-8a75-33e41257c255',
entity_type: 'Threat-Actor-Individual',
representative: {
main: 'Jhon Threat Actor Individual',
secondary: 'This organized threat actor individual.'
},
height: [
{
measure: 1.2192,
date_seen: '2024-02-15T23:00:00.000Z'
}
],
updated_at: '2024-02-23T09:53:26.245Z',
modified: '2024-02-23T09:53:26.245Z'
};

const user = {
_index: 'opencti_internal_objects-000001',
internal_id: '421781aa-52cb-4019-abf1-3f8c3c8617bd',
entity_type: 'User',
representative: {
main: 'Plop'
},
user_confidence_level: {
max_confidence: 73,
overrides: [
{
max_confidence: 77,
entity_type: 'Report'
}
]
},
updated_at: '2024-02-23T09:57:32.006Z'
};

it('validates correct payloads', () => {
expect(() => validateDataBeforeIndexing(malware)).not.toThrowError();
expect(() => validateDataBeforeIndexing(threatActorIndividual)).not.toThrowError();
expect(() => validateDataBeforeIndexing(user)).not.toThrowError();
});

it('throws error on invalid payloads', () => {
let invalidUser: any = {
...user,
entity_type: undefined,
};
expect(() => validateDataBeforeIndexing(invalidUser))
.toThrowError('Validation against schema failed: element has no entity_type');

invalidUser = {
...user,
entity_type: 'Wrong',
};
expect(() => validateDataBeforeIndexing(invalidUser))
.toThrowError('Validation against schema failed: this entity_type is not supported');

invalidUser = {
...user,
user_confidence_level: {
// max_confidence: 73, // missing mandatory field in a single object
overrides: [{
max_confidence: 77,
entity_type: 'Report'
}]
},
};
expect(() => validateDataBeforeIndexing(invalidUser))
.toThrowError('Validation against schema failed on attribute [user_confidence_level]: mandatory field [max_confidence] is not present');

invalidUser = {
...user,
user_confidence_level: {
max_confidence: 73,
overrides: [{
max_confidence: 77,
// entity_type: 'Report' // missing mandatory field in a inner multiple object
}]
},
};
expect(() => validateDataBeforeIndexing(invalidUser))
.toThrowError('Validation against schema failed on attribute [overrides]: mandatory field [entity_type] is not present');

invalidUser = {
...user,
user_confidence_level: {
max_confidence: null, // mandatory field to null
overrides: [{
max_confidence: 77,
entity_type: 'Report'
}]
},
};
expect(() => validateDataBeforeIndexing(invalidUser))
.toThrowError('Validation against schema failed on attribute [max_confidence]: this mandatory field cannot be nil');

const invalidThreatActorIndividual = {
...threatActorIndividual,
height: [{
// measure: 1.2192, // missing mandatory field in a multiple object
date_seen: '2024-02-15T23:00:00.000Z'
}],
};
expect(() => validateDataBeforeIndexing(invalidThreatActorIndividual))
.toThrowError('Validation against schema failed on attribute [height]: mandatory field [measure] is not present');
});
});
Loading

0 comments on commit 7f524f6

Please sign in to comment.