From 0530e3caf47c69df8304ee04a00f88606cbcfe1a Mon Sep 17 00:00:00 2001 From: Angelique Date: Mon, 22 Jan 2024 18:03:12 +0100 Subject: [PATCH] [backend/frontend] adding decay chart in indicator lifecycle overview. (#2859) --- .../observations/indicators/DecayChart.tsx | 167 +++++++++++++++ .../observations/indicators/DecayDialog.tsx | 41 ++-- .../indicators/IndicatorDetails.tsx | 8 +- .../src/schema/relay.schema.graphql | 10 + .../opencti-graphql/src/generated/graphql.ts | 30 +++ .../modules/indicator/decay-chart-domain.ts | 49 +++++ .../src/modules/indicator/indicator-domain.ts | 13 ++ .../modules/indicator/indicator-resolver.ts | 2 + .../src/modules/indicator/indicator.graphql | 11 + .../tests/01-unit/domain/decay-test.ts | 194 +++++++++++++----- 10 files changed, 448 insertions(+), 77 deletions(-) create mode 100644 opencti-platform/opencti-front/src/private/components/observations/indicators/DecayChart.tsx create mode 100644 opencti-platform/opencti-graphql/src/modules/indicator/decay-chart-domain.ts diff --git a/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayChart.tsx b/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayChart.tsx new file mode 100644 index 0000000000000..27f3485eedfca --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayChart.tsx @@ -0,0 +1,167 @@ +import React, { FunctionComponent } from 'react'; +import { IndicatorDetails_indicator$data } from '@components/observations/indicators/__generated__/IndicatorDetails_indicator.graphql'; +import Chart from '@components/common/charts/Chart'; +import { ApexOptions } from 'apexcharts'; +import moment from 'moment'; +import { useTheme } from '@mui/styles'; +import { Theme } from '@mui/material/styles/createTheme'; + +interface DecayChartProps { + indicator: IndicatorDetails_indicator$data, +} + +const DecayChart : FunctionComponent = ({ indicator }) => { + const theme = useTheme(); + + const liveScoreColor = theme.palette.success.main; + const reactionPointColor = theme.palette.text.primary; + const scoreColor = theme.palette.info.main; + const revokeColor = theme.palette.secondary.main; + + const chartLabelsTextColor = theme.palette.info.contrastText; + const chartInfoTextColor = theme.palette.text.primary; + const chartBackgroundColor = theme.palette.background.default; + + const graphLinesAnnotations = []; + + // Horizontal lines that shows reaction points + if (indicator.decay_applied_rule?.decay_points) { + indicator.decay_applied_rule.decay_points.forEach((reactionPoint) => { + const lineReactionValue = { + y: reactionPoint, + borderColor: reactionPoint === indicator.x_opencti_score ? scoreColor : reactionPointColor, + label: { + borderColor: reactionPoint === indicator.x_opencti_score ? scoreColor : reactionPointColor, + offsetY: 0, + style: { + color: chartLabelsTextColor, + background: reactionPoint === indicator.x_opencti_score ? scoreColor : reactionPointColor, + }, + text: reactionPoint === indicator.x_opencti_score ? `Score: ${reactionPoint}` : `${reactionPoint}`, + }, + }; + graphLinesAnnotations.push(lineReactionValue); + }); + + // Horizontal "red" area that show the revoke zone + const revokeScoreArea = { + y: indicator.decay_applied_rule.decay_revoke_score + 1, // trick to have a red line even if revoke score is 0 + y2: 0, + borderColor: revokeColor, + fillColor: revokeColor, + label: { + text: `Revoke zone: ${indicator.decay_applied_rule.decay_revoke_score}`, + borderColor: revokeColor, + style: { + color: chartLabelsTextColor, + background: revokeColor, + }, + }, + }; + graphLinesAnnotations.push(revokeScoreArea); + } + + // Horizontal line that the current / live score + const liveScoreLine = { + y: indicator.decayLiveDetails?.live_score, + borderColor: liveScoreColor, + label: { + borderColor: liveScoreColor, + style: { + color: chartLabelsTextColor, + background: liveScoreColor, + }, + text: `Live score:${indicator.decayLiveDetails?.live_score}`, + }, + }; + graphLinesAnnotations.push(liveScoreLine); + + // Time in millisecond cannot be set as number in GraphQL because it's too long + // So the time in data is stored as Date and must be converted to time in ms to be drawn on the chart. + const liveScoreApexFormat: { x: number; y: number }[] = []; + if (indicator.decayChartData && indicator.decayChartData.live_score_serie) { + indicator.decayChartData.live_score_serie.forEach((dataPoint) => { + liveScoreApexFormat.push({ + x: moment(dataPoint.time).valueOf(), + y: dataPoint.score, + }); + }); + } + + const pointAnnotations = []; + pointAnnotations.push({ + x: new Date().getTime(), + y: indicator.decayLiveDetails?.live_score, + marker: { + fillColor: liveScoreColor, + }, + }); + + const chartOptions: ApexOptions = { + chart: { + id: 'Decay graph', + toolbar: { show: false }, + type: 'line', + background: chartBackgroundColor, + }, + xaxis: { + type: 'datetime', + title: { + text: 'Days', + style: { + color: chartInfoTextColor, + }, + }, + labels: { + style: { + colors: chartInfoTextColor, + }, + }, + }, + yaxis: { + min: 0, + max: 100, + title: { + text: 'Score', + style: { + color: chartInfoTextColor, + }, + }, + labels: { + style: { + colors: chartInfoTextColor, + }, + }, + }, + annotations: { + yaxis: graphLinesAnnotations, + points: pointAnnotations, + }, + grid: { show: false }, + colors: [ + theme.palette.primary.main, + ], + stroke: { + curve: 'smooth', + }, + tooltip: { + theme: theme.palette.mode, // ApexChart uses 'dark'/'light', exactly the same values as we use in OpenCTI. + }, + }; + + const series = [ + { + name: 'Score with decay', // this is the text on the popover + data: liveScoreApexFormat, + }, + ]; + + return ( + + ); +}; + +export default DecayChart; diff --git a/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayDialog.tsx b/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayDialog.tsx index 8e7d2d746a468..18aac88c8972e 100644 --- a/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayDialog.tsx +++ b/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayDialog.tsx @@ -13,6 +13,7 @@ import { SxProps } from '@mui/material'; import { Theme } from '@mui/material/styles/createTheme'; import { useTheme } from '@mui/styles'; import { IndicatorDetails_indicator$data } from '@components/observations/indicators/__generated__/IndicatorDetails_indicator.graphql'; +import DecayChart from '@components/observations/indicators/DecayChart'; import { useFormatter } from '../../../../components/i18n'; interface DecayDialogContentProps { @@ -34,22 +35,32 @@ const DecayDialogContent : FunctionComponent = ({ indic const decayHistory = indicator.decay_history ? [...indicator.decay_history] : []; const decayLivePoints = indicatorDecayDetails?.live_points ? [...indicatorDecayDetails.live_points] : []; - const decayReactionPoints = indicator.decay_applied_rule?.decay_points ?? []; - const currentScoreLineStyle = { + const currentLiveScoreLineStyle = { color: theme.palette.success.main, fontWeight: 'bold', }; + + const currentScoreLineStyle = { + color: theme.palette.info.main, + fontWeight: 'bold', + }; + const revokeScoreLineStyle = { - color: theme.palette.error.main, + color: theme.palette.secondary.main, + }; + + const normalHistoryLineStyle = { + color: theme.palette.text.primary, }; + const decayFullHistory: LabelledDecayHistory[] = []; decayHistory.map((history, index) => ( decayFullHistory.push({ score: history.score, updated_at: history.updated_at, label: index === 0 ? 'Score at creation' : 'Score updated', - style: index === decayHistory.length - 1 ? currentScoreLineStyle : {}, + style: history.score === indicator.x_opencti_score ? currentScoreLineStyle : normalHistoryLineStyle, }) )); @@ -58,7 +69,7 @@ const DecayDialogContent : FunctionComponent = ({ indic score: history.score, updated_at: history.updated_at, label: index === decayLivePoints.length - 1 ? 'Revoke score' : 'Score update planned', - style: index === decayLivePoints.length - 1 ? revokeScoreLineStyle : {}, + style: index === decayLivePoints.length - 1 ? revokeScoreLineStyle : normalHistoryLineStyle, }) )); @@ -67,7 +78,7 @@ const DecayDialogContent : FunctionComponent = ({ indic score: indicatorDecayDetails.live_score, updated_at: new Date(), label: 'Current live score', - style: currentScoreLineStyle, + style: currentLiveScoreLineStyle, }); } @@ -82,7 +93,10 @@ const DecayDialogContent : FunctionComponent = ({ indic spacing={3} style={{ borderColor: 'white', borderWidth: 1 }} > - + + + + {t_i18n('Lifecycle key information')} @@ -109,18 +123,7 @@ const DecayDialogContent : FunctionComponent = ({ indic - - - {t_i18n('Applied decay rule')} - -
    -
  • {t_i18n('Base score:')} { indicator.decay_base_score }
  • -
  • {t_i18n('Lifetime (in days):')} { indicator.decay_applied_rule?.decay_lifetime ?? 'Not set'}
  • -
  • {t_i18n('Pound factor:')} { indicator.decay_applied_rule?.decay_pound ?? 'Not set'}
  • -
  • {t_i18n('Revoke score:')} { indicator.decay_applied_rule?.decay_revoke_score ?? 'Not set'}
  • -
  • {t_i18n('Reaction points:')} {decayReactionPoints.join(', ')}
  • -
-
+
); diff --git a/opencti-platform/opencti-front/src/private/components/observations/indicators/IndicatorDetails.tsx b/opencti-platform/opencti-front/src/private/components/observations/indicators/IndicatorDetails.tsx index 34a0086932de7..6a69555ff6e26 100644 --- a/opencti-platform/opencti-front/src/private/components/observations/indicators/IndicatorDetails.tsx +++ b/opencti-platform/opencti-front/src/private/components/observations/indicators/IndicatorDetails.tsx @@ -121,7 +121,7 @@ const IndicatorDetailsComponent: FunctionComponent {t_i18n('Lifecycle details')} @@ -235,6 +235,12 @@ const IndicatorDetails = createFragmentContainer(IndicatorDetailsComponent, { updated_at } } + decayChartData { + live_score_serie { + time + score + } + } objectLabel { edges { node { diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index 422dc959e7290..e2e10f8a4bff9 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -10529,6 +10529,15 @@ type DecayLiveDetails { live_points: [DecayHistory!] } +type DecayChartPoint { + time: DateTime! + score: Int! +} + +type DecayChartData { + live_score_serie: [DecayChartPoint!] +} + type Indicator implements BasicObject & StixObject & StixCoreObject & StixDomainObject { id: ID! standard_id: String! @@ -10585,6 +10594,7 @@ type Indicator implements BasicObject & StixObject & StixCoreObject & StixDomain decay_applied_rule: DecayRule decay_history: [DecayHistory!] decayLiveDetails: DecayLiveDetails + decayChartData: DecayChartData creators: [Creator!] toStix: String importFiles(first: Int, prefixMimeType: String): FileConnection diff --git a/opencti-platform/opencti-graphql/src/generated/graphql.ts b/opencti-platform/opencti-graphql/src/generated/graphql.ts index 929a730b79459..06216704a74e5 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -5159,6 +5159,17 @@ export enum DataSourcesOrdering { XOpenctiWorkflowId = 'x_opencti_workflow_id' } +export type DecayChartData = { + __typename?: 'DecayChartData'; + live_score_serie?: Maybe>; +}; + +export type DecayChartPoint = { + __typename?: 'DecayChartPoint'; + score: Scalars['Int']['output']; + time: Scalars['DateTime']['output']; +}; + export type DecayHistory = { __typename?: 'DecayHistory'; score: Scalars['Int']['output']; @@ -8820,6 +8831,7 @@ export type Indicator = BasicObject & StixCoreObject & StixDomainObject & StixOb createdBy?: Maybe; created_at: Scalars['DateTime']['output']; creators?: Maybe>; + decayChartData?: Maybe; decayLiveDetails?: Maybe; decay_applied_rule?: Maybe; decay_base_score?: Maybe; @@ -27357,6 +27369,8 @@ export type ResolversTypes = ResolversObject<{ DataSourceEdge: ResolverTypeWrapper & { node: ResolversTypes['DataSource'] }>; DataSourcesOrdering: DataSourcesOrdering; DateTime: ResolverTypeWrapper; + DecayChartData: ResolverTypeWrapper; + DecayChartPoint: ResolverTypeWrapper; DecayHistory: ResolverTypeWrapper; DecayLiveDetails: ResolverTypeWrapper; DecayRule: ResolverTypeWrapper; @@ -28077,6 +28091,8 @@ export type ResolversParentTypes = ResolversObject<{ DataSourceConnection: Omit & { edges?: Maybe>> }; DataSourceEdge: Omit & { node: ResolversParentTypes['DataSource'] }; DateTime: Scalars['DateTime']['output']; + DecayChartData: DecayChartData; + DecayChartPoint: DecayChartPoint; DecayHistory: DecayHistory; DecayLiveDetails: DecayLiveDetails; DecayRule: DecayRule; @@ -30280,6 +30296,17 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ + live_score_serie?: Resolver>, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}>; + +export type DecayChartPointResolvers = ResolversObject<{ + score?: Resolver; + time?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type DecayHistoryResolvers = ResolversObject<{ score?: Resolver; updated_at?: Resolver; @@ -31449,6 +31476,7 @@ export type IndicatorResolvers, ParentType, ContextType>; created_at?: Resolver; creators?: Resolver>, ParentType, ContextType>; + decayChartData?: Resolver, ParentType, ContextType>; decayLiveDetails?: Resolver, ParentType, ContextType>; decay_applied_rule?: Resolver, ParentType, ContextType>; decay_base_score?: Resolver, ParentType, ContextType>; @@ -36890,6 +36918,8 @@ export type Resolvers = ResolversObject<{ DataSourceConnection?: DataSourceConnectionResolvers; DataSourceEdge?: DataSourceEdgeResolvers; DateTime?: GraphQLScalarType; + DecayChartData?: DecayChartDataResolvers; + DecayChartPoint?: DecayChartPointResolvers; DecayHistory?: DecayHistoryResolvers; DecayLiveDetails?: DecayLiveDetailsResolvers; DecayRule?: DecayRuleResolvers; diff --git a/opencti-platform/opencti-graphql/src/modules/indicator/decay-chart-domain.ts b/opencti-platform/opencti-graphql/src/modules/indicator/decay-chart-domain.ts new file mode 100644 index 0000000000000..ae51bd9a0485b --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/indicator/decay-chart-domain.ts @@ -0,0 +1,49 @@ +import moment from 'moment'; +import type { BasicStoreEntityIndicator } from './indicator-types'; +import { computeTimeFromExpectedScore, type DecayRule } from './decay-domain'; + +export const dayToMs = (days: number) => { + return days * 24 * 60 * 60 * 1000; +}; + +export const msToDay = (milli: number) => { + return milli / 24 / 60 / 60 / 1000; +}; + +export interface DecayPoint { + time: Date, + score: number, +} +export interface DecayChartData { + live_score_serie: DecayPoint[] +} + +/** + * Compute all data point (x as time, y as score value) needed to draw the decay mathematical curve as time serie. + * @param indicator indicator with decay data + * @param scoreList + */ +export const computeChartDecayAlgoSerie = (indicator: BasicStoreEntityIndicator, scoreList: number[]): DecayPoint[] => { + if (indicator.decay_applied_rule && indicator.decay_applied_rule.decay_points && indicator.decay_base_score_date) { + const decayData: DecayPoint[] = []; + const startDateInMs = moment(indicator.decay_base_score_date).valueOf(); + scoreList.forEach((scoreValue) => { + const timeForScore = dayToMs(computeTimeFromExpectedScore(indicator.decay_base_score, scoreValue, indicator.decay_applied_rule as DecayRule)); + const point: DecayPoint = { time: moment(startDateInMs + timeForScore).toDate(), score: scoreValue }; + decayData.push(point); + }); + return decayData; + } + return []; +}; + +/** + * Compute all time scores needed to draw the chart from base score to 0. + */ +export const computScoreList = (maxScore:number): number[] => { + const scoreArray: number[] = []; + for (let i = 0; i <= maxScore; i += 1) { + scoreArray.push(maxScore - i); + } + return scoreArray; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/indicator/indicator-domain.ts b/opencti-platform/opencti-graphql/src/modules/indicator/indicator-domain.ts index 823a1bf5c61d3..648608c15ef1c 100644 --- a/opencti-platform/opencti-graphql/src/modules/indicator/indicator-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/indicator/indicator-domain.ts @@ -39,6 +39,7 @@ import { import { isModuleActivated } from '../../domain/settings'; import { prepareDate } from '../../utils/format'; import { FilterMode, FilterOperator, OrderingMode } from '../../generated/graphql'; +import { computeChartDecayAlgoSerie, computScoreList, type DecayChartData } from './decay-chart-domain'; export const findById = (context: AuthContext, user: AuthUser, indicatorId: string) => { return storeLoadById(context, user, indicatorId, ENTITY_TYPE_INDICATOR); @@ -65,6 +66,18 @@ export const getDecayDetails = async (context: AuthContext, user: AuthUser, indi return details; }; +export const getDecayChartData = async (context: AuthContext, user: AuthUser, indicator: BasicStoreEntityIndicator) => { + if (!indicator.decay_applied_rule) { + return null; + } + const scoreList = computScoreList(indicator.decay_base_score); + const liveScoreSerie = computeChartDecayAlgoSerie(indicator, scoreList); + + const chartData: DecayChartData = { + live_score_serie: liveScoreSerie, + }; + return chartData; +}; export const findIndicatorsForDecay = (context: AuthContext, user: AuthUser, maxSize: number) => { const filters = { orderBy: 'decay_next_reaction_date', diff --git a/opencti-platform/opencti-graphql/src/modules/indicator/indicator-resolver.ts b/opencti-platform/opencti-graphql/src/modules/indicator/indicator-resolver.ts index 0de3f8844acaa..35a49997876d1 100644 --- a/opencti-platform/opencti-graphql/src/modules/indicator/indicator-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/indicator/indicator-resolver.ts @@ -3,6 +3,7 @@ import { batchObservables, findAll, findById, + getDecayChartData, getDecayDetails, indicatorsDistributionByEntity, indicatorsNumber, @@ -53,6 +54,7 @@ const indicatorResolvers: Resolvers = { killChainPhases: (indicator, _, context) => killChainPhasesLoader.load(indicator.id, context, context.user), observables: (indicator, _, context) => batchObservablesLoader.load(indicator.id, context, context.user), decayLiveDetails: (indicator, _, context) => getDecayDetails(context, context.user, indicator), + decayChartData: (indicator, _, context) => getDecayChartData(context, context.user, indicator), }, Mutation: { indicatorAdd: (_, { input }, context) => addIndicator(context, context.user, input), diff --git a/opencti-platform/opencti-graphql/src/modules/indicator/indicator.graphql b/opencti-platform/opencti-graphql/src/modules/indicator/indicator.graphql index 1e92029fb25e5..98c7f7a8b77af 100644 --- a/opencti-platform/opencti-graphql/src/modules/indicator/indicator.graphql +++ b/opencti-platform/opencti-graphql/src/modules/indicator/indicator.graphql @@ -49,6 +49,16 @@ type DecayLiveDetails { live_points: [DecayHistory!] } +# Do we merge with DecayHistory ? +type DecayChartPoint { + time: DateTime! + score: Int! +} + +type DecayChartData { + live_score_serie: [DecayChartPoint!] +} + type Indicator implements BasicObject & StixObject & StixCoreObject & StixDomainObject { id: ID! # internal_id standard_id: String! @@ -165,6 +175,7 @@ type Indicator implements BasicObject & StixObject & StixCoreObject & StixDomain decay_applied_rule: DecayRule decay_history: [DecayHistory!] decayLiveDetails: DecayLiveDetails + decayChartData: DecayChartData # Technical creators: [Creator!] toStix: String diff --git a/opencti-platform/opencti-graphql/tests/01-unit/domain/decay-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/domain/decay-test.ts index ddb55abe0a301..eb65b99e52dac 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/domain/decay-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/domain/decay-test.ts @@ -13,6 +13,7 @@ import { } from '../../../src/modules/indicator/decay-domain'; import { computeIndicatorDecayPatch, type IndicatorPatch } from '../../../src/modules/indicator/indicator-domain'; import type { BasicStoreEntityIndicator } from '../../../src/modules/indicator/indicator-types'; +import { computeChartDecayAlgoSerie, computScoreList } from '../../../src/modules/indicator/decay-chart-domain'; describe('Decay formula testing', () => { it('should compute score', () => { @@ -94,40 +95,22 @@ describe('Decay formula testing', () => { }); describe('Decay update testing', () => { - const getDefaultIndicatorEntity = () => { + it('should move to next score and update next reaction date for default rule', () => { + // GIVEN an Indicator with decay that is on the first decay point and has next reaction point const indicatorInput: Partial = { - x_opencti_score: 50, + decay_applied_rule: FALLBACK_DECAY_RULE, decay_base_score: 100, - decay_history: [], + x_opencti_score: 50, valid_from: moment().subtract('5', 'days').toDate(), - valid_until: moment().add('5', 'days').toDate() + valid_until: moment().add('5', 'days').toDate(), + decay_history: [{ + updated_at: moment().subtract('5', 'days').toDate(), + score: 50, + }], }; - return indicatorInput as BasicStoreEntityIndicator; - }; - - const defaultDecayRule: DecayRule = { - decay_lifetime: 30, - decay_points: [80, 60, 20], - decay_pound: 0.5, - decay_revoke_score: 10, - enabled: true, - id: 'decay-test-next-score', - indicator_types: [], - order: 0 - }; - - it('should move to next score and update next reaction date for default rule', () => { - // GIVEN an Indicator with decay that is on the first decay point and has next reaction point - const indicatorInput = getDefaultIndicatorEntity(); - indicatorInput.decay_applied_rule = FALLBACK_DECAY_RULE; - indicatorInput.x_opencti_score = 50; - indicatorInput.decay_history = [{ - updated_at: indicatorInput.valid_from, - score: 50, - }]; // WHEN next reaction point is computed - const patchResult = computeIndicatorDecayPatch(indicatorInput); + const patchResult = computeIndicatorDecayPatch(indicatorInput as BasicStoreEntityIndicator); // THEN expect(patchResult?.revoked, 'This indicator should not be revoked.').toBeUndefined(); @@ -139,15 +122,26 @@ describe('Decay update testing', () => { it('should move to next score and update next reaction date', () => { // GIVEN an Indicator with decay that is on the first decay point and has next reaction point - const indicatorInput = getDefaultIndicatorEntity(); - indicatorInput.decay_applied_rule = defaultDecayRule; - indicatorInput.decay_applied_rule.decay_points = [100, 80, 50, 20]; - indicatorInput.decay_applied_rule.decay_revoke_score = 10; - indicatorInput.x_opencti_score = 100; - indicatorInput.decay_history = []; + const indicatorInput : Partial = { + x_opencti_score: 100, + decay_base_score: 100, + decay_history: [], + valid_from: moment().subtract('5', 'days').toDate(), + valid_until: moment().add('5', 'days').toDate(), + decay_applied_rule: { + decay_lifetime: 30, + decay_points: [100, 80, 50, 20], + decay_pound: 0.5, + decay_revoke_score: 10, + enabled: true, + id: 'decay-test-next-score', + indicator_types: [], + order: 0 + } + }; // WHEN next reaction point is computed - const patchResult = computeIndicatorDecayPatch(indicatorInput); + const patchResult = computeIndicatorDecayPatch(indicatorInput as BasicStoreEntityIndicator); // THEN expect(patchResult?.revoked, 'This indicator should not be revoked.').toBeUndefined(); @@ -159,16 +153,28 @@ describe('Decay update testing', () => { it('should be revoked when revoke score is reached', () => { // GIVEN an Indicator with decay that is on the last decay point and has next a revoke score - const indicatorInput = getDefaultIndicatorEntity(); - indicatorInput.decay_applied_rule = defaultDecayRule; - indicatorInput.decay_applied_rule.decay_points = [100, 80, 50, 20]; - indicatorInput.decay_applied_rule.decay_revoke_score = 10; - indicatorInput.x_opencti_score = 20; - indicatorInput.decay_history = []; - indicatorInput.decay_history.push({ updated_at: new Date(2023, 1), score: 100 }); + const indicatorInput : Partial = { + x_opencti_score: 20, + decay_base_score: 100, + decay_history: [ + { updated_at: new Date(2023, 1), score: 100 }, + ], + valid_from: moment().subtract('5', 'days').toDate(), + valid_until: moment().add('5', 'days').toDate(), + decay_applied_rule: { + decay_lifetime: 30, + decay_points: [100, 80, 50, 20], + decay_pound: 0.5, + decay_revoke_score: 10, + enabled: true, + id: 'decay-test-next-score', + indicator_types: [], + order: 0 + } + }; // WHEN next reaction point is computed - const patchResult = computeIndicatorDecayPatch(indicatorInput); + const patchResult = computeIndicatorDecayPatch(indicatorInput as BasicStoreEntityIndicator); // THEN expect(patchResult?.revoked, 'This indicator should be revoked.').toBeTruthy(); @@ -180,14 +186,26 @@ describe('Decay update testing', () => { it('should revoke when current score is already lower than revoke score', () => { // GIVEN an Indicator with a stable score that is already lower than revoke score // use case that should not happen with a normal usage - const indicatorInput = getDefaultIndicatorEntity(); - indicatorInput.decay_applied_rule = defaultDecayRule; - indicatorInput.decay_applied_rule.decay_points = [100, 80, 50, 20]; - indicatorInput.decay_applied_rule.decay_revoke_score = 50; - indicatorInput.x_opencti_score = 30; + const indicatorInput : Partial = { + x_opencti_score: 30, + decay_base_score: 100, + decay_history: [], + valid_from: moment().subtract('5', 'days').toDate(), + valid_until: moment().add('5', 'days').toDate(), + decay_applied_rule: { + decay_lifetime: 30, + decay_points: [100, 80, 50, 20], + decay_pound: 0.5, + decay_revoke_score: 50, + enabled: true, + id: 'decay-test-next-score', + indicator_types: [], + order: 0 + } + }; // WHEN next reaction point is computed - const patchResult = computeIndicatorDecayPatch(indicatorInput); + const patchResult = computeIndicatorDecayPatch(indicatorInput as BasicStoreEntityIndicator); // THEN expect(patchResult?.revoked, 'This indicator should be revoked.').toBeTruthy(); @@ -198,14 +216,26 @@ describe('Decay update testing', () => { it('should revoke when revoke score is higher than all decay points', () => { // GIVEN an Indicator with revoke score higher than all decay points // use case that should not happen with a normal usage - const indicatorInput = getDefaultIndicatorEntity(); - indicatorInput.decay_applied_rule = defaultDecayRule; - indicatorInput.decay_applied_rule.decay_points = [80, 50, 20]; - indicatorInput.decay_applied_rule.decay_revoke_score = 100; - indicatorInput.x_opencti_score = 50; + const indicatorInput : Partial = { + x_opencti_score: 50, + decay_base_score: 100, + decay_history: [], + valid_from: moment().subtract('5', 'days').toDate(), + valid_until: moment().add('5', 'days').toDate(), + decay_applied_rule: { + decay_lifetime: 30, + decay_points: [80, 50, 20], + decay_pound: 0.5, + decay_revoke_score: 100, + enabled: true, + id: 'decay-test-next-score', + indicator_types: [], + order: 0 + } + }; // WHEN next reaction point is computed - const patchResult = computeIndicatorDecayPatch(indicatorInput) as IndicatorPatch; + const patchResult = computeIndicatorDecayPatch(indicatorInput as BasicStoreEntityIndicator) as IndicatorPatch; // THEN expect(patchResult.revoked, 'This indicator should be revoked.').toBeTruthy(); @@ -215,10 +245,16 @@ describe('Decay update testing', () => { it('should do nothing when decay rule is null', () => { // GIVEN an Indicator with no decay rule - const indicatorInput = getDefaultIndicatorEntity(); + const indicatorInput : Partial = { + x_opencti_score: 50, + decay_base_score: 100, + decay_history: [], + valid_from: moment().subtract('5', 'days').toDate(), + valid_until: moment().add('5', 'days').toDate() + }; // WHEN next reaction point is computed - const patchResult = computeIndicatorDecayPatch(indicatorInput) as IndicatorPatch; + const patchResult = computeIndicatorDecayPatch(indicatorInput as BasicStoreEntityIndicator) as IndicatorPatch; // THEN expect(patchResult, 'No database operation should be done.').toBeNull(); @@ -268,3 +304,47 @@ describe('Decay live detailed data testing (subset of indicatorDecayDetails quer expect(result[0].score, 'The live score should be = score when data required for computation are missing.').toBe(40); }); }); + +describe('Decay chart data generation', () => { + it('should compute score list correctly', () => { + const result: number[] = computScoreList(81); + + expect(result.length).toBe(82); // from 81 to zero included + expect(result[0]).toBe(81); + expect(result[81]).toBe(0); + }); + + it('should compute nothing for score < 0', () => { + const result: number[] = computScoreList(-12); + expect(result.length).toBe(0); + }); + + it('should compute live score serie correctly', () => { + // YYYY-MM-DDTHH:mm:ss.sssZ + const startDate = new Date('2023-12-15T00:00:00.000Z'); + + const timeSerie: number[] = computScoreList(100); + const indicator: Partial = { + x_opencti_score: 100, + decay_base_score: 100, + decay_base_score_date: startDate, + decay_applied_rule: FALLBACK_DECAY_RULE, + valid_from: startDate, + valid_until: moment().add(FALLBACK_DECAY_RULE.decay_lifetime, 'days').toDate() + }; + + const result = computeChartDecayAlgoSerie(indicator as BasicStoreEntityIndicator, timeSerie); + + expect(result[0].score).toBe(100); + expect(moment(result[0].time).format('DD/MM/YYYY'), 'Base core 100 should be at start date').toBe('15/12/2023'); + + expect(result[25].score).toBe(75); + expect(moment(result[25].time).format('DD/MM/YYYY'), 'expect 1').toBe('20/12/2023'); + + expect(result[50].score).toBe(50); + expect(moment(result[50].time).format('DD/MM/YYYY'), 'expect 1').toBe('29/01/2024'); + + expect(result[100].score).toBe(0); + expect(moment(result[100].time).format('DD/MM/YYYY'), 'expect 1').toBe('14/12/2024'); + }); +});