How the OpenCTI Indicator Decay Manager Works: A Technical Deep Dive

The OpenCTI indicator decay manager automatically ages Indicators by running a scheduled background job that selects stale indicators, recomputes their scores based on Decay Rules, and updates their validity status.

OpenCTI, an open-source cyber threat intelligence platform, implements automatic indicator lifecycle management through the indicator decay manager. This system ensures that Indicators (IP addresses, file hashes, domains, etc.) automatically lose relevance over time according to configurable decay rules, reducing false positives from outdated threat intelligence.

What Is the Indicator Decay Manager?

The indicator decay manager is a background service registered with OpenCTI's generic manager framework. It operates on a scheduled interval to process batches of Indicators whose decay_next_reaction_date has passed. For each selected Indicator, the manager:

  1. Retrieves the associated Decay Rule (containing score points, revoke thresholds, and reaction dates)
  2. Calculates the next stable score based on the decay curve
  3. Determines if the Indicator should be revoked
  4. Updates the Indicator's score, history, and next reaction timestamp

The implementation resides primarily in opencti-platform/opencti-graphql/src/manager/indicatorDecayManager.ts with core logic delegated to src/modules/indicator/indicator-domain.ts.

Configuration and Initialization

Manager Settings

The indicator decay manager is controlled through configuration keys loaded at startup in indicatorDecayManager.ts:

Config Key Default Purpose
indicator_decay_manager:enabled true Master switch to enable/disable the manager
indicator_decay_manager:lock_key indicator_decay_manager_lock Distributed lock key preventing parallel execution across cluster nodes
indicator_decay_manager:interval 60000 (1 minute) Milliseconds between scheduler invocations
indicator_decay_manager:batch_size 10000 Maximum indicators processed per execution cycle
const INDICATOR_DECAY_MANAGER_ENABLED = booleanConf('indicator_decay_manager:enabled', true);
const INDICATOR_DECAY_MANAGER_KEY = conf.get('indicator_decay_manager:lock_key') || 'indicator_decay_manager_lock';
const SCHEDULE_TIME = conf.get('indicator_decay_manager:interval') || 60000;
const BATCH_SIZE = conf.get('indicator_decay_manager:batch_size') || 10000;

Registration with the Manager Framework

The manager registers itself using the registerManager function, providing a cron-like handler that the framework invokes on the defined interval:

const INDICATOR_DECAY_MANAGER_DEFINITION: ManagerDefinition = {
  id: 'INDICATOR_DECAY_MANAGER',
  label: 'Indicator decay manager',
  executionContext: 'indicator_decay_manager',
  cronSchedulerHandler: {
    handler: indicatorDecayHandler,
    interval: SCHEDULE_TIME,
    lockKey: INDICATOR_DECAY_MANAGER_KEY,
  },
  enabledByConfig: INDICATOR_DECAY_MANAGER_ENABLED,
  enabledToStart() { return this.enabledByConfig; },
  enabled() { return this.enabledByConfig; },
};

registerManager(INDICATOR_DECAY_MANAGER_DEFINITION);

How the Decay Process Works

Step 1: Selecting Stale Indicators

The indicatorDecayHandler function creates an execution context and queries for indicators requiring decay using findIndicatorsForDecay from src/modules/indicator/indicator-domain.ts:

const context = executionContext('indicator_decay_manager');
const indicatorsToUpdate = await findIndicatorsForDecay(context, DECAY_MANAGER_USER, BATCH_SIZE);

The selector queries the database with specific filters:

export const findIndicatorsForDecay = (context, user, maxSize) => {
  const filters = {
    orderBy: 'decay_next_reaction_date',
    orderMode: OrderingMode.Asc,
    mode: FilterMode.And,
    filters: [
      { key: ['decay_next_reaction_date'], values: [prepareDate()], operator: FilterOperator.Lt },
      { key: ['revoked'], values: ['false'] },
    ],
    filterGroups: [],
  };
  const args = { filters, maxSize };
  return fullEntitiesList(context, user, [ENTITY_TYPE_INDICATOR], args);
};

This query retrieves up to BATCH_SIZE indicators where:

  • decay_next_reaction_date is less than the current time (stale)
  • revoked is false (not already revoked)
  • Results are ordered by reaction date (oldest first)

Step 2: Computing the Next Decay Step

For each selected indicator, the handler invokes updateIndicatorDecayScore, which delegates to computeIndicatorDecayPatch in indicator-domain.ts. This function calculates the new score based on the associated Decay Rule's decay_points array:

const newStableScore = model.decay_points.find(p => (p || indicator.x_opencti_score) < indicator.x_opencti_score) || model.decay_revoke_score;
if (newStableScore) {
  const newDecayHistoryPoint = { updated_at: new Date(), score: newStableScore, updated_by: user.id };
  const decayHistory = computeIndicatorDecayHistory([...indicator.decay_history ?? []], newDecayHistoryPoint);
  patch = { x_opencti_score: newStableScore, decay_history: decayHistory };
  
  if (newStableScore <= model.decay_revoke_score) {
    patch = { ...patch, revoked: true, x_opencti_detection: false };
  } else {
    const nextScoreReactionDate = computeNextScoreReactionDate(indicator.decay_base_score, newStableScore, model, moment(indicator.valid_from));
    if (nextScoreReactionDate) {
      patch = { ...patch, decay_next_reaction_date: nextScoreReactionDate };
    }
  }
}

The logic follows this decision tree:

  • Find the next lower score in the decay rule's points array
  • If the new score is at or below decay_revoke_score, mark the indicator as revoked and disable detection
  • Otherwise, calculate the next reaction date using computeNextScoreReactionDate and schedule future processing

Step 3: Persisting Updates

The computed patch is persisted using patchAttribute:

return patchAttribute(context, user, indicator.id, ENTITY_TYPE_INDICATOR, patch);

This updates the indicator's document in Elasticsearch with the new score, history, revocation status, and next reaction date.

Error Handling and Resilience

The indicator decay manager implements robust error isolation. Within the processing loop, individual indicator failures are caught and logged without aborting the batch:

try {
  await updateIndicatorDecayScore(context, DECAY_MANAGER_USER, indicator);
} catch (e) {
  logApp.error('[OPENCTI-MODULE] Error when processing decay, skipping.', { cause: e, id: indicatorsToUpdate[i].id });
  errorCount += 1;
}

After processing, the manager emits a summary log:

if (errorCount > 0) {
  logApp.error('[OPENCTI-MODULE] Indicator decay manager got errors.', { errors_count: errorCount, indicators_count: indicatorsToUpdate.length });
} else {
  logApp.debug('[OPENCTI-MODULE] Indicator decay manager updated', { indicators_count: indicatorsToUpdate.length });
}

This design ensures that a corrupted indicator or transient database error cannot stall the decay process for thousands of other indicators.

Code Example: Manual Trigger

For testing or administrative purposes, you can manually invoke the decay handler. This respects the same configuration and batch size limits defined in the manager:

import { indicatorDecayHandler } from '../src/manager/indicatorDecayManager';

(async () => {
  // Directly invoke the handler – useful for unit tests or forced runs
  await indicatorDecayHandler();
  console.log('Decay run finished');
})();

In production, the cron scheduler automatically invokes this handler according to the indicator_decay_manager:interval configuration. Manual invocation is primarily used in integration tests located at tests/03-integration/04-manager/indicatorDecayManager-test.ts.

Summary

  • The indicator decay manager is a scheduled background service in OpenCTI that automatically ages Indicators based on configurable Decay Rules.
  • Configuration in indicatorDecayManager.ts controls enablement, interval (default 60 seconds), batch size (default 10,000), and distributed locking.
  • The manager queries for stale indicators using findIndicatorsForDecay in indicator-domain.ts, selecting only non-revoked indicators whose decay_next_reaction_date has passed.
  • Each indicator is processed via updateIndicatorDecayScore, which calculates the next score step, updates history, determines revocation status, and schedules the next reaction date.
  • Error isolation ensures individual processing failures are logged without stopping the batch, maintaining system stability.

Frequently Asked Questions

How often does the indicator decay manager run?

By default, the indicator decay manager runs every 60 seconds (60,000 milliseconds). This interval is controlled by the indicator_decay_manager:interval configuration key in indicatorDecayManager.ts. You can adjust this value based on your performance requirements and the volume of indicators in your deployment.

What happens when an indicator's score reaches the revoke threshold?

When the computed newStableScore is less than or equal to the Decay Rule's decay_revoke_score, the manager automatically sets revoked: true and x_opencti_detection: false on the indicator. This marks the indicator as invalid and removes it from active detection rules, effectively retiring the threat intelligence once it has aged sufficiently.

Can the indicator decay manager process indicators in parallel across multiple OpenCTI instances?

Yes, the manager supports distributed deployments through the indicator_decay_manager:lock_key configuration. This lock key (default: indicator_decay_manager_lock) prevents multiple OpenCTI instances from running the decay process simultaneously, ensuring that the same indicator is not processed twice in a clustered environment.

Where can I find the integration tests for the indicator decay manager?

The integration tests are located at tests/03-integration/04-manager/indicatorDecayManager-test.ts in the OpenCTI repository. These tests demonstrate the manager's behavior by manually invoking indicatorDecayHandler and verifying that indicators transition through decay states correctly, including score reductions and eventual revocation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →