From e4b938ecc3e8a4cd635baf8d40d27159ed4a8843 Mon Sep 17 00:00:00 2001 From: "A. Jard" Date: Tue, 30 Jan 2024 16:38:26 +0100 Subject: [PATCH] [backend/frontend] adding decay chart in indicator lifecycle overview (#2859) --- .../observations/indicators/DecayChart.tsx | 207 ++++++++++++++++++ .../observations/indicators/DecayDialog.tsx | 85 +++---- .../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, 511 insertions(+), 98 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..d548152c0ca8d --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/observations/indicators/DecayChart.tsx @@ -0,0 +1,207 @@ +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 decayCurveColor = theme.palette.primary.main; + const reactionPointColor = theme.palette.text.primary; + const scoreColor = theme.palette.success.main; + const revokeColor = theme.palette.secondary.main; + + const chartLabelBackgroundColor = theme.palette.background.paper; + const chartInfoTextColor = theme.palette.text.primary; + const chartBackgroundColor = theme.palette.background.default; + const graphLineThickness = 4; + + // 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 convertTimeForChart = (time: Date) => { + return moment(time).valueOf(); + }; + + // This is the chart serie data, aka the curve. + const decayCurveDataPoints: { x: number; y: number }[] = []; + if (indicator.decayChartData && indicator.decayChartData.live_score_serie) { + indicator.decayChartData.live_score_serie.forEach((dataPoint) => { + decayCurveDataPoints.push({ + x: convertTimeForChart(dataPoint.time), + y: dataPoint.score, + }); + }); + } + + 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: reactionPoint === indicator.x_opencti_score ? scoreColor : chartInfoTextColor, + background: chartLabelBackgroundColor, + }, + text: `${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 score: ${indicator.decay_applied_rule.decay_revoke_score}`, + borderColor: revokeColor, + style: { + color: revokeColor, + background: chartLabelBackgroundColor, + }, + }, + }; + graphLinesAnnotations.push(revokeScoreArea); + } + + const pointAnnotations = []; + if (indicator.decayChartData?.live_score_serie && indicator.decayChartData?.live_score_serie.length > 0) { + // circle on the curve that show the live score + pointAnnotations.push({ + x: new Date().getTime(), + y: indicator.decayLiveDetails?.live_score, + marker: { + fillColor: decayCurveColor, + strokeColor: chartInfoTextColor, + strokeWidth: 1, + size: graphLineThickness, + fillOpacity: 0.2, + }, + }); + + // circle on the curve that show the current stable score + const currentScore = indicator.decayChartData?.live_score_serie.find((point) => point.score === indicator.x_opencti_score); + if (currentScore !== undefined) { + pointAnnotations.push({ + x: convertTimeForChart(currentScore.time), + y: currentScore.score, + marker: { + fillColor: scoreColor, + strokeColor: chartInfoTextColor, + size: graphLineThickness + 1, + strokeWidth: 1, + fillOpacity: 1, + radius: graphLineThickness, + }, + label: { + text: `Score:${currentScore.score}`, + position: 'right', + borderColor: scoreColor, + borderWidth: 2, + style: { + color: scoreColor, + background: chartLabelBackgroundColor, + }, + }, + }); + } + } + + const chartOptions: ApexOptions = { + chart: { + id: 'Decay graph', + toolbar: { show: false }, + type: 'line', + background: chartBackgroundColor, + selection: { + enabled: false, + }, + }, + xaxis: { + type: 'datetime', + title: { + text: 'Days', + style: { + color: chartInfoTextColor, + }, + }, + labels: { + style: { + colors: chartInfoTextColor, + }, + datetimeFormatter: { + year: 'yyyy', + month: 'MMM yyyy', + day: 'dd MMM yyyy', + }, + }, + }, + yaxis: { + min: 0, + max: 100, + title: { + text: 'Score', + style: { + color: chartInfoTextColor, + }, + }, + labels: { + style: { + colors: chartInfoTextColor, + }, + }, + }, + annotations: { + yaxis: graphLinesAnnotations, + points: pointAnnotations, + }, + grid: { show: false }, + colors: [ + decayCurveColor, + ], + tooltip: { + theme: theme.palette.mode, // ApexChart uses 'dark'/'light', exactly the same values as we use in OpenCTI. + x: { + show: true, + format: 'dd MMM yyyy', + }, + }, + forecastDataPoints: { + // this draw the dash line after live score point + count: indicator.decayLiveDetails?.live_score, + fillOpacity: 0.5, + strokeWidth: graphLineThickness, + dashArray: 8, + }, + }; + + const series = [ + { + name: 'Score', // this is the text on the popover + data: decayCurveDataPoints, + }, + ]; + + 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..2f0b4a154e8a3 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 @@ -1,6 +1,5 @@ import React, { FunctionComponent } from 'react'; import DialogContent from '@mui/material/DialogContent'; -import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -13,6 +12,8 @@ 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 moment from 'moment-timezone'; import { useFormatter } from '../../../../components/i18n'; interface DecayDialogContentProps { @@ -28,49 +29,64 @@ export interface LabelledDecayHistory { const DecayDialogContent : FunctionComponent = ({ indicator }) => { const theme = useTheme(); - const { t_i18n, fldt } = useFormatter(); + const { t_i18n } = useFormatter(); const indicatorDecayDetails = indicator.decayLiveDetails; 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 = { - color: theme.palette.success.main, - fontWeight: 'bold', + const getScoreLabelFor = (score: number) => { + if (score === indicator.decay_base_score) { + return 'Score at creation'; + } if (score === indicator.x_opencti_score) { + return 'Current stable score'; + } if (score === indicator.decay_applied_rule?.decay_revoke_score) { + return 'Revoke score'; + } + return 'Stability threshold'; }; - const revokeScoreLineStyle = { - color: theme.palette.error.main, + + const getStyleFor = (score: number) => { + if (score === indicator.x_opencti_score) { + return { + color: theme.palette.success.main, + fontWeight: 'bold', + }; + } if (score === indicator.decay_applied_rule?.decay_revoke_score) { + return { color: theme.palette.secondary.main }; + } + return { color: theme.palette.text.primary }; + }; + + const getDateAsTextFor = (history: LabelledDecayHistory) => { + if (indicator.x_opencti_score === null || indicator.x_opencti_score === undefined) { + return 'N/A'; + } if (history.score < indicator.x_opencti_score) { + return moment(history.updated_at).fromNow(); + } + return moment(history.updated_at).format('DD MMM yyyy HH:mm A'); }; + const decayFullHistory: LabelledDecayHistory[] = []; - decayHistory.map((history, index) => ( + decayHistory.map((history) => ( decayFullHistory.push({ score: history.score, updated_at: history.updated_at, - label: index === 0 ? 'Score at creation' : 'Score updated', - style: index === decayHistory.length - 1 ? currentScoreLineStyle : {}, + label: getScoreLabelFor(history.score), + style: getStyleFor(history.score), }) )); - decayLivePoints.map((history, index) => ( + decayLivePoints.map((history) => ( decayFullHistory.push({ score: history.score, updated_at: history.updated_at, - label: index === decayLivePoints.length - 1 ? 'Revoke score' : 'Score update planned', - style: index === decayLivePoints.length - 1 ? revokeScoreLineStyle : {}, + label: getScoreLabelFor(history.score), + style: getStyleFor(history.score), }) )); - if (indicatorDecayDetails && indicatorDecayDetails.live_score && indicatorDecayDetails.live_score !== indicator.x_opencti_score) { - decayFullHistory.push({ - score: indicatorDecayDetails.live_score, - updated_at: new Date(), - label: 'Current live score', - style: currentScoreLineStyle, - }); - } - decayFullHistory.sort((a, b) => { return new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime(); }); @@ -82,10 +98,10 @@ const DecayDialogContent : FunctionComponent = ({ indic spacing={3} style={{ borderColor: 'white', borderWidth: 1 }} > - - - {t_i18n('Lifecycle key information')} - + + + + @@ -101,7 +117,7 @@ const DecayDialogContent : FunctionComponent = ({ indic {t_i18n(history.label)} {history.score} - {fldt(history.updated_at)} + {getDateAsTextFor(history)} ); })} @@ -109,18 +125,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 c93a1b435a4c0..478b8eed08525 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 { id value diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index de469548344d7..8176b0722f3b7 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -10576,6 +10576,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! @@ -10632,6 +10641,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 863f6ee07045a..7e2642d0d4e5a 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -5181,6 +5181,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']; @@ -8852,6 +8863,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; @@ -27411,6 +27423,8 @@ export type ResolversTypes = ResolversObject<{ DataSourceEdge: ResolverTypeWrapper & { node: ResolversTypes['DataSource'] }>; DataSourcesOrdering: DataSourcesOrdering; DateTime: ResolverTypeWrapper; + DecayChartData: ResolverTypeWrapper; + DecayChartPoint: ResolverTypeWrapper; DecayHistory: ResolverTypeWrapper; DecayLiveDetails: ResolverTypeWrapper; DecayRule: ResolverTypeWrapper; @@ -28140,6 +28154,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; @@ -30359,6 +30375,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; @@ -31539,6 +31566,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>; @@ -36996,6 +37024,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..ba271ef3f2968 --- /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 computeScoreList = (maxScore:number): number[] => { + const scoreArray: number[] = []; + for (let i = maxScore; i >= 0; i -= 1) { + scoreArray.push(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 769cd9ec3f834..61a2283731890 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 { } from './decay-domain'; import { isModuleActivated } from '../../domain/settings'; import { prepareDate } from '../../utils/format'; +import { computeChartDecayAlgoSerie, computeScoreList, 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 = computeScoreList(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 833649ca82a25..c1e12ea06c734 100644 --- a/opencti-platform/opencti-graphql/src/modules/indicator/indicator-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/indicator/indicator-resolver.ts @@ -2,6 +2,7 @@ import { addIndicator, findAll, findById, + getDecayChartData, getDecayDetails, indicatorsDistributionByEntity, indicatorsNumber, @@ -51,6 +52,7 @@ const indicatorResolvers: Resolvers = { killChainPhases: (indicator, _, context) => loadThroughDenormalized(context, context.user, indicator, INPUT_KILLCHAIN, { sortBy: 'phase_name' }), observables: (indicator, args, context) => observablesPaginated(context, context.user, indicator.id, args), 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 c2edc7714150f..37b0c4f857250 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..a99caa77c5151 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, computeScoreList } 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[] = computeScoreList(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[] = computeScoreList(-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[] = computeScoreList(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'); + }); +});