CassandraCTI Documentation

CassandraCTI is a modular, asynchronous Cyber Threat Intelligence aggregator. It collects events from multiple sources, deduplicates them via SHA1 fingerprinting, routes them through configurable rules, renders messages from Jinja2 templates, and delivers them to Microsoft Teams or Discord webhooks.

Named after the Trojan prophetess condemned to warn without being believed — CassandraCTI makes sure your threat intel actually reaches the people who need it.

Pipeline at a glance

Sources   RSS / Atom · ransomware.live · Red Flag Domains
   
Filter    regex deny/allow · max_items_per_source · date filter
   
Dedupe    SHA1(source + url + title) → SQLite event store
   
Route     source prefix · tag match · regex (title / source)
   
Render    Jinja2 templates per route
   
Send      Teams MessageCard · Discord Embed  (retry + throttle)

Installation

Requirements

  • Python 3.10 or later
  • pip

From source

# Clone the repository
git clone https://github.com/franckferman/CassandraCTI.git
cd CassandraCTI

# Install dependencies
pip install -r requirements.txt

# Or install as a package (installs the cassandra CLI entry point)
pip install .

Configuration directory

By default, CassandraCTI looks for its configuration in a platform-specific directory:

PlatformPath
Linux~/.config/cassandra-cti/ (or $XDG_CONFIG_HOME/cassandra-cti/)
macOS~/Library/Application Support/cassandra-cti/
Windows%APPDATA%\cassandra-cti\

The directory contains two files:

~/.config/cassandra-cti/
├── config.yaml         ← sources, routes, filters, store, metrics
├── connectors.yaml     ← transport definitions (webhook URLs)
└── templates/          ← custom Jinja2 templates (optional)

Quick Start

1. Initialize config files

cassandra init

2. Add a Teams webhook connector

cassandra add-connector \
  --id "teams-soc" \
  --webhook-url "https://your-tenant.webhook.office.com/..." \
  --theme-color "0078D7"

3. Add threat intel sources

# RSS feed
cassandra add-source rss \
  --name "CERT-FR Alertes" \
  --url "https://cert.ssi.gouv.fr/alerte/feed/" \
  --tags "cert,fr"

# Ransomware tracker
cassandra add-source ransomware_live

# Malicious domain list
cassandra add-source redflag

4. Validate configuration

cassandra doctor config
cassandra doctor connector --id teams-soc

5. Run

# One-shot
cassandra run

# Loop every 5 minutes
cassandra run --loop --interval 300

# Dry run (no messages sent)
cassandra run --dry-run --verbose

config.yaml

The main configuration file. Controls sources, routing, filtering, storage, and metrics. Supports ${VAR_NAME} environment variable substitution anywhere in the file.

config.yaml — full reference
schema_version: 1

scheduler:
  mode: oneshot          # "oneshot" | "loop"
  interval_seconds: 300  # used when mode: loop

sources:
  rss:
    enabled: true
    feeds:
      - name: "CERT-FR Alertes"
        url: "https://cert.ssi.gouv.fr/alerte/feed/"
        tags: ["cert", "fr"]
  ransomware_live:
    enabled: true
    lookback_days: 30      # how far back to fetch attacks
    url: "..."              # optional override
  red_flag_domains:
    enabled: true
    base_url: "..."         # optional override

filters:
  title_regex_deny:       # drop events whose title matches
    - "(?i)sponsored"
    - "(?i)webinar"
  title_regex_allow: []   # if non-empty, ONLY pass matching titles
  max_items_per_source: 50

transports:
  use: ["teams-soc", "discord-alert"]  # reference connector IDs

routes:
  - name: "cert-alerts"
    include_tags: ["cert"]
    transports: ["teams-soc"]
    template: "templates/rss_default.j2"   # optional

store:
  sqlite_path: ".cassandra_cti.db"
  seen_ttl_days: 90       # purge events older than N days

logging:
  level: "INFO"           # DEBUG | INFO | WARNING | ERROR

metrics:
  enabled: true
  host: "0.0.0.0"
  port: 9108

Scheduler

KeyTypeDefaultDescription
modestringoneshotoneshot runs once and exits. loop repeats every interval_seconds.
interval_secondsint300Loop interval in seconds. Only used when mode: loop.

Sources

KeyTypeDescription
rss.enabledboolEnable RSS/Atom source fetching.
rss.feedslistList of {name, url, tags} feed objects.
ransomware_live.enabledboolEnable ransomware.live source.
ransomware_live.lookback_daysintHow many days back to fetch ransomware events. Default: 30.
ransomware_live.urlstringOptional override for the ransomware.live JSON endpoint.
red_flag_domains.enabledboolEnable Red Flag Domains source.
red_flag_domains.base_urlstringOptional override for the daily list base URL.

Filters

KeyTypeDescription
title_regex_denylist[str]Python regex patterns. Events whose title matches any pattern are dropped.
title_regex_allowlist[str]If non-empty, only events matching at least one pattern are kept.
max_items_per_sourceintMaximum events accepted per source per run. Default: 50.

Transports reference

KeyDescription
transports.useList of connector IDs (defined in connectors.yaml) to activate.

Routes

Routes are evaluated in order. The first matching route wins. An event not matched by any route is silently dropped.

KeyTypeDescription
namestringUnique route identifier (used in logs).
include_sourceslist[str]Match by source ID prefix or exact name. e.g. rss: matches all RSS feeds.
include_tagslist[str]Match if any event tag is in this list.
include_regexstringPython regex tested against title and source.
transportslist[str]Connector IDs to deliver matched events to.
templatestringPath to a Jinja2 template file. Optional — falls back to transport default.

Store

KeyTypeDefaultDescription
sqlite_pathstring.cassandra_cti.dbPath to the SQLite deduplication database. Relative paths are resolved from the config file directory.
seen_ttl_daysint90Events older than this are purged on each run. Set to 0 to disable.

Metrics

KeyTypeDefaultDescription
enabledboolfalseStart a Prometheus HTTP metrics endpoint.
hoststring0.0.0.0Bind address for the metrics server.
portint9108Port for the metrics HTTP server.

connectors.yaml

Defines transport instances (webhook endpoints, formatting options). Kept separate from config.yaml so it can be secrets-managed independently. Supports ${VAR_NAME} substitution.

connectors.yaml
connectors:
  - id: "teams-soc"
    type: "teams"
    params:
      webhook_url: ${MSTEAMS_WEBHOOK_SOC}
      theme_color: "0078D7"
      emojis: true
      throttle_ms: 1000
      batching:
        enabled: true
        max_items: 5

  - id: "discord-alert"
    type: "discord"
    params:
      webhook_url: ${DISCORD_WEBHOOK_URL}
      username: "CassandraCTI"
      avatar_url: "https://..."
      emojis: true
      throttle_ms: 500

Teams connector params

ParamTypeRequiredDescription
webhook_urlstringYesMicrosoft Teams incoming webhook URL.
theme_colorstringNoHex color without # for the card border. Default: black.
emojisboolNoPrepend computed emoji to message title. Default: true.
emoji_mapdictNoCustom source_id → emoji overrides.
throttle_msintNoMinimum ms between sends. Minimum 1000ms for Teams. Default: 1000.
batching.enabledboolNoGroup multiple events into a single card. Default: false.
batching.max_itemsintNoMax events per batched card. Default: 10.

Theme color suggestions

ColorHexRecommended use
Blue0078D7General / Microsoft
Purple8E44ADVendor news
OrangeD83B01General news
Green107C10Ransomware
RedC0392BMalicious domains

Discord connector params

ParamTypeRequiredDescription
webhook_urlstringYesDiscord webhook URL.
usernamestringNoDisplay name for the bot.
avatar_urlstringNoAvatar image URL for the bot.
emojisboolNoPrepend emoji to embed title.
emoji_mapdictNoCustom source_id → emoji overrides.
throttle_msintNoMinimum ms between sends. Default: 500.
batching.enabledboolNoGroup events into a single embed.
batching.max_itemsintNoMax events per batched embed.

Discord limits enforced automatically: embed title is truncated at 256 chars, description at 4000 chars (with ... (truncated) suffix).

Environment Variables

Runtime behavior can be overridden via environment variables without touching config files.

VariableDescription
CTI_CONNECTORSOverride path to connectors.yaml.
CTI_LOGLEVELOverride log level at runtime: DEBUG, INFO, WARNING, ERROR.
CTI_SINCEOnly process events published after this ISO8601 date (e.g. 2025-03-01). Overrides --since flag.
CTI_NO_DEDUPESet to any non-empty value to skip deduplication entirely.
CTI_DRY_RUNSet to any non-empty value to log delivery actions without sending anything.

Config files also support ${VAR_NAME} substitution, which is resolved at load time. Use this to inject webhook URLs and other secrets without committing them to disk:

connectors:
  - id: "teams-soc"
    type: "teams"
    params:
      webhook_url: ${MSTEAMS_WEBHOOK_SOC}

RSS / Atom Feeds

The RSS source fetches and parses any standard RSS or Atom feed. Each feed is identified as rss:{name}, enabling per-feed routing rules.

Configuration

sources:
  rss:
    enabled: true
    feeds:
      - name: "CERT-FR Alertes"
        url: "https://cert.ssi.gouv.fr/alerte/feed/"
        tags: ["cert", "fr"]
      - name: "BleepingComputer"
        url: "https://www.bleepingcomputer.com/feed/"
        tags: ["news"]

Bulk import from CSV

# Format: Name,URL,Tags (tags are pipe-separated)
cassandra import-feeds feeds.csv

Event fields

FieldValue
sourcerss:{name} — e.g. rss:CERT-FR Alertes
titleFeed entry title
urlFeed entry link
summaryEntry content/description (HTML stripped)
published_atNormalized to UTC
tagsTags defined per feed in config
rawFull feedparser entry object

Pre-configured feeds (30+)

CERTs CERT-FR AlertesCERT-FR Avis Microsoft Microsoft SecurityMicrosoft SentinelMSRC Vendors Cisco TalosTrend MicroProofpointCheckpoint Research SentinelOneRedCanaryPalo Alto Unit 42 Kaspersky SecurelistRecorded FutureGoogle TAGATT LevelBlue News Krebs on SecurityBleepingComputerDark Reading Hacker NewsThreatpostSANS ISC Diary Schneier on SecurityGraham CluleyVirusBulletin InfoSecurity MagazineCyber-News.fr Technical Adam Chester (XPN)ModexpJames Forshaw

Full list with URLs available in config.example.yaml.

Ransomware Live

Tracks ransomware group attacks from ransomware.live. Each attack becomes a deduplicated event with rich metadata.

Configuration

sources:
  ransomware_live:
    enabled: true
    lookback_days: 30   # fetch attacks up to 30 days old

Event fields

FieldValue
sourceransomware.live
title{victim} by {group}
tags["ransomware"]
raw.group_nameRansomware group name
raw.countryVictim country (ISO code)
raw.activityGroup activity status
raw.post_urlLeak site post URL
raw.descriptionVictim description
raw.discoveredDiscovery date
raw.publishedPublication date

Country flag emoji is automatically detected from the victim domain TLD — 🇫🇷 🇬🇧 🇩🇪 🇺🇸 🇯🇵 ... — and prepended to the title when emojis: true.

Red Flag Domains

Downloads the daily malicious domain list from red.flag.domains. One event is generated per run containing a preview and link to the full list.

Configuration

sources:
  red_flag_domains:
    enabled: true

Event fields

FieldValue
sourcered.flag.domains
titleRed Flag Domains – YYYY-MM-DD
summaryFull domain list (newline-separated)
tags["domains"]
raw.fileDownloaded filename
raw.countTotal domain count
raw.dateList date (YYYY-MM-DD)

The domains_list.j2 template formats this event with a 50-domain preview and a direct link to the full list.

Microsoft Teams

Sends Adaptive Cards (MessageCard format) to a Teams incoming webhook. Supports single-event and batched delivery.

Example connector

connectors:
  - id: "teams-cert"
    type: "teams"
    params:
      webhook_url: ${MSTEAMS_WEBHOOK_CERT}
      theme_color: "0078D7"
      emojis: true
      throttle_ms: 1000
      batching:
        enabled: false
        max_items: 10

Payload format (MessageCard)

{
  "@type": "MessageCard",
  "@context": "http://schema.org/extensions",
  "themeColor": "0078D7",
  "summary": "event title",
  "title": "emoji + title",
  "text": "rendered template body",
  "potentialAction": [{
    "@type": "OpenUri",
    "name": "View Source",
    "targets": [{ "os": "default", "uri": "event url" }]
  }]
}

Rate limiting and retries

  • Minimum throttle_ms: 1000 between sends (Teams enforces this)
  • Automatic retry on 429 (Too Many Requests) with exponential backoff
  • 3 retry attempts total

Discord

Sends rich Embed messages to a Discord webhook. Supports custom bot username and avatar.

Example connector

connectors:
  - id: "discord-alert"
    type: "discord"
    params:
      webhook_url: ${DISCORD_WEBHOOK_URL}
      username: "CassandraCTI"
      avatar_url: "https://..."
      emojis: true
      throttle_ms: 500
      batching:
        enabled: true
        max_items: 5

Payload format (Embed)

{
  "username": "CassandraCTI",
  "avatar_url": "https://...",
  "embeds": [{
    "title": "max 256 chars",
    "description": "max 4000 chars",
    "color": 5814783,
    "url": "event url (single events only)"
  }]
}

Limits and retries

  • Title truncated at 256 chars, description at 4000 chars
  • 5 retry attempts with exponential backoff (1s to 60s cap)

Routing Rules

Routes match incoming events and determine which transports receive them. First match wins — routes are evaluated in the order defined in config.yaml. Events not matched by any route are silently dropped.

Match by source prefix

routes:
  - name: "all-rss"
    include_sources: ["rss:"]          # matches rss:CERT-FR, rss:Krebs, ...
    transports: ["discord-alert"]

Match by exact source

routes:
  - name: "ransomware"
    include_sources: ["ransomware.live"]
    transports: ["teams-soc"]

Match by tag

routes:
  - name: "cert-only"
    include_tags: ["cert"]
    transports: ["teams-cert", "discord-alert"]

Match by regex

routes:
  - name: "critical-cves"
    include_regex: "(?i)(critical|zero.day|0-day|CVE)"
    transports: ["teams-soc", "discord-alert"]

Regex is tested against both title and source. You can combine matchers: a route matches if any of its configured criteria match the event.

Fan-out to multiple transports

routes:
  - name: "cert-alerts"
    include_tags: ["cert"]
    transports: ["teams-cert", "discord-alert", "teams-mgmt"]  # all three receive it

With a custom template

routes:
  - name: "ransomware-rich"
    include_sources: ["ransomware.live"]
    transports: ["teams-soc"]
    template: "templates/ransomware_card.j2"

cassandra run

Execute the aggregation and delivery pipeline.

cassandra run [OPTIONS]
OptionTypeDescription
--configPATHPath to config.yaml.
--connectorsPATHPath to connectors.yaml.
--loopflagRun in loop mode (uses scheduler.interval_seconds).
--intervalINTOverride loop interval in seconds.
--sourcesTEXTComma-separated source filter. E.g. "rss:,ransomware.live".
--dry-runflagLog delivery actions without sending anything.
--verboseflagSet log level to DEBUG.
--sinceTEXTOnly process events after this date (ISO8601 or YYYY-MM-DD).
--no-dedupeflagSkip deduplication — resend all events.

Examples

# Standard run
cassandra run

# Loop every 10 minutes, verbose output
cassandra run --loop --interval 600 --verbose

# Only fetch ransomware and CERT feeds
cassandra run --sources "ransomware.live,rss:CERT-FR Alertes"

# Backfill the past week without deduplication
cassandra run --since 2025-03-12 --no-dedupe

# Test a config change without sending anything
cassandra run --dry-run --verbose

cassandra init

Scaffold the configuration directory with default files.

cassandra init [--config PATH] [--connectors PATH]

Creates config.yaml, connectors.yaml, and a templates/ directory in the default config path (or the paths you specify). Skips files that already exist — safe to re-run.

cassandra add-source

Add a source to config.yaml.

# Add an RSS feed
cassandra add-source rss \
  --name "CERT-FR Alertes" \
  --url "https://cert.ssi.gouv.fr/alerte/feed/" \
  --tags "cert,fr"

# Enable ransomware.live
cassandra add-source ransomware_live

# Enable Red Flag Domains
cassandra add-source redflag

Bulk import from CSV

# CSV format: Name,URL,Tags  (tags are pipe-separated)
cassandra import-feeds feeds.csv

cassandra add-connector

Add a Teams connector to connectors.yaml.

cassandra add-connector \
  --id "teams-soc" \
  --webhook-url "https://..." \
  --theme-color "0078D7" \
  --emojis

cassandra routes-add

Add or update a routing rule in config.yaml.

cassandra routes-add \
  --name "cert-alerts" \
  --include-tag "cert" \
  --transports "teams-cert,discord-alert"

cassandra doctor

Validate configuration and test transport connectivity.

# Validate YAML schema
cassandra doctor config

# Send a test message to a connector
cassandra doctor connector --id teams-soc

cassandra backfill

Replay past events from the SQLite store to a specific transport.

cassandra backfill --to teams-soc --since 2025-03-01

Events are sent in batches of 10. Delivery is recorded in the store so events are not re-sent on the next regular run.

db-reset · seen-clear · list

cassandra list

Display currently configured sources, routes, and connectors.

cassandra list

cassandra db-reset

Delete the SQLite database (clears all deduplication history). Prompts for confirmation unless --force is passed.

cassandra db-reset         # prompts for confirmation
cassandra db-reset --force  # skip confirmation

Destructive. All delivery tracking is lost — every event will be re-sent on the next run.

cassandra seen-clear

Selectively clear deduplication history without resetting the entire database.

# Clear all events from a specific feed
cassandra seen-clear --source-prefix "rss:BleepingComputer"

# Clear events seen before a date
cassandra seen-clear --before 2025-01-01

# Clear events from a source after a date
cassandra seen-clear --source-prefix "ransomware.live" --since 2025-03-01

Jinja2 Templates

Every message sent by CassandraCTI is rendered from a Jinja2 template. Templates can be assigned per route; if omitted, each transport uses its built-in default.

Built-in templates

FileUse case
templates/rss_default.j2Standard single RSS event (source, summary, link)
templates/discord_default.j2Discord-optimized layout with >>> quote blocks
templates/ransomware_card.j2Rich ransomware alert (group, country, activity, dates)
templates/domains_list.j2Malicious domain list with 50-domain preview and full link
templates/batch_default.j2Batched multi-event summary with emoji per event

Template context variables

VariableTypeDescription
titlestrEvent title
sourcestrSource ID (e.g. rss:CERT-FR)
summarystrEvent body text
urlstr | NoneEvent URL
emojistrComputed emoji for this source/event
eventslist[Event]All events in the current batch
rawdictRaw source data (fields vary by source type)

Custom template example

templates/my_cert_card.j2{# Custom Teams card for CERT alerts #}
🚨 **{{ title }}**

| Field | Value |
|---|---|
| **Source** | {{ source }} |
| **Date** | {{ raw.published if raw.published else "unknown" }} |

{{ summary | truncate(300) }}

[Read more]({{ url }})

Assign it to a route:

routes:
  - name: "cert-alerts"
    include_tags: ["cert"]
    transports: ["teams-cert"]
    template: "templates/my_cert_card.j2"

Docker

Build and run

docker build -t cassandra-cti .

docker run \
  -v /path/to/your/config:/config \
  -e MSTEAMS_WEBHOOK_SOC="https://..." \
  -e DISCORD_WEBHOOK_URL="https://..." \
  cassandra-cti

Loop mode

docker run \
  -v /path/to/your/config:/config \
  -e MSTEAMS_WEBHOOK_SOC="https://..." \
  cassandra-cti \
  cassandra run --config /config/config.yaml --loop --interval 300

Docker Compose

services:
  cassandra-cti:
    build: .
    volumes:
      - ./config:/config
    environment:
      - MSTEAMS_WEBHOOK_SOC=${MSTEAMS_WEBHOOK_SOC}
      - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
    command: cassandra run --config /config/config.yaml --loop --interval 300
    restart: unless-stopped

Dockerfile overview

FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml /app/
RUN pip install --upgrade pip && pip install .
COPY . /app
ENV CTI_CONNECTORS=/config/connectors.yaml
ENV PYTHONUNBUFFERED=1
CMD ["cassandra", "run", "--config", "/config/config.yaml"]

Mount a volume at /config containing config.yaml and connectors.yaml. Inject webhook URLs via environment variables — never commit them to the image.

Prometheus Metrics

When metrics.enabled: true, CassandraCTI exposes a Prometheus-compatible endpoint at http://0.0.0.0:9108/metrics.

MetricTypeLabelsDescription
cassandra_cti_events_sentCounterrouteEvents successfully delivered.
cassandra_cti_fetch_totalCountersource, statusFetch attempts per source (ok or err).

Prometheus scrape config

scrape_configs:
  - job_name: "cassandra-cti"
    static_configs:
      - targets: ["cassandra-cti:9108"]

Architecture

Module map

ModuleResponsibility
cli.pyTyper CLI — all user-facing commands
main.pyCore pipeline orchestration (fetch → filter → route → send)
models.pyEvent dataclass
config.pyYAML loading, env substitution, Settings / RouteDef / TransportDef
config_schema.pyPydantic schema validation for config.yaml
router.pyRoute matching (source / tag / regex)
store.pySQLite deduplication and delivery tracking
emoji.pyEmoji selection logic (source map + country detection)
util.pyexpand_env(), canon() URL normalizer, make_event_id() SHA1
sources/rss.pyRSS/Atom feed fetcher
sources/ransomware_live.pyransomware.live JSON fetcher
sources/redflag.pyRed Flag Domains daily list scraper
transports/teams.pyMicrosoft Teams MessageCard transport
transports/discord.pyDiscord Embed transport
transports/__init__.pyTransport factory registry

Deduplication

Event IDs are generated as SHA1(source + canonical_url + title). This makes IDs deterministic and stable across restarts. Two SQLite tables track state:

  • events — stores event metadata with first/last seen timestamps
  • deliveries — tracks per-transport delivery status (ok / failed)

An event is only sent to a transport if it has no successful delivery record for that transport. Using --no-dedupe or setting CTI_NO_DEDUPE bypasses this check.

Async concurrency

All source fetches run concurrently as asyncio tasks. Transport sends are sequential per route (respecting throttle delays) but multiple routes can fan out to different transports. The event loop is created fresh for each cassandra run invocation; loop mode calls run_once() in a sleep-loop.