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:
| Platform | Path |
|---|---|
| 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
| Key | Type | Default | Description |
|---|---|---|---|
| mode | string | oneshot | oneshot runs once and exits. loop repeats every interval_seconds. |
| interval_seconds | int | 300 | Loop interval in seconds. Only used when mode: loop. |
Sources
| Key | Type | Description |
|---|---|---|
| rss.enabled | bool | Enable RSS/Atom source fetching. |
| rss.feeds | list | List of {name, url, tags} feed objects. |
| ransomware_live.enabled | bool | Enable ransomware.live source. |
| ransomware_live.lookback_days | int | How many days back to fetch ransomware events. Default: 30. |
| ransomware_live.url | string | Optional override for the ransomware.live JSON endpoint. |
| red_flag_domains.enabled | bool | Enable Red Flag Domains source. |
| red_flag_domains.base_url | string | Optional override for the daily list base URL. |
Filters
| Key | Type | Description |
|---|---|---|
| title_regex_deny | list[str] | Python regex patterns. Events whose title matches any pattern are dropped. |
| title_regex_allow | list[str] | If non-empty, only events matching at least one pattern are kept. |
| max_items_per_source | int | Maximum events accepted per source per run. Default: 50. |
Transports reference
| Key | Description |
|---|---|
| transports.use | List 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.
| Key | Type | Description |
|---|---|---|
| name | string | Unique route identifier (used in logs). |
| include_sources | list[str] | Match by source ID prefix or exact name. e.g. rss: matches all RSS feeds. |
| include_tags | list[str] | Match if any event tag is in this list. |
| include_regex | string | Python regex tested against title and source. |
| transports | list[str] | Connector IDs to deliver matched events to. |
| template | string | Path to a Jinja2 template file. Optional — falls back to transport default. |
Store
| Key | Type | Default | Description |
|---|---|---|---|
| sqlite_path | string | .cassandra_cti.db | Path to the SQLite deduplication database. Relative paths are resolved from the config file directory. |
| seen_ttl_days | int | 90 | Events older than this are purged on each run. Set to 0 to disable. |
Metrics
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | bool | false | Start a Prometheus HTTP metrics endpoint. |
| host | string | 0.0.0.0 | Bind address for the metrics server. |
| port | int | 9108 | Port 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
| Param | Type | Required | Description |
|---|---|---|---|
| webhook_url | string | Yes | Microsoft Teams incoming webhook URL. |
| theme_color | string | No | Hex color without # for the card border. Default: black. |
| emojis | bool | No | Prepend computed emoji to message title. Default: true. |
| emoji_map | dict | No | Custom source_id → emoji overrides. |
| throttle_ms | int | No | Minimum ms between sends. Minimum 1000ms for Teams. Default: 1000. |
| batching.enabled | bool | No | Group multiple events into a single card. Default: false. |
| batching.max_items | int | No | Max events per batched card. Default: 10. |
Theme color suggestions
| Color | Hex | Recommended use |
|---|---|---|
| Blue | 0078D7 | General / Microsoft |
| Purple | 8E44AD | Vendor news |
| Orange | D83B01 | General news |
| Green | 107C10 | Ransomware |
| Red | C0392B | Malicious domains |
Discord connector params
| Param | Type | Required | Description |
|---|---|---|---|
| webhook_url | string | Yes | Discord webhook URL. |
| username | string | No | Display name for the bot. |
| avatar_url | string | No | Avatar image URL for the bot. |
| emojis | bool | No | Prepend emoji to embed title. |
| emoji_map | dict | No | Custom source_id → emoji overrides. |
| throttle_ms | int | No | Minimum ms between sends. Default: 500. |
| batching.enabled | bool | No | Group events into a single embed. |
| batching.max_items | int | No | Max 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.
| Variable | Description |
|---|---|
| CTI_CONNECTORS | Override path to connectors.yaml. |
| CTI_LOGLEVEL | Override log level at runtime: DEBUG, INFO, WARNING, ERROR. |
| CTI_SINCE | Only process events published after this ISO8601 date (e.g. 2025-03-01). Overrides --since flag. |
| CTI_NO_DEDUPE | Set to any non-empty value to skip deduplication entirely. |
| CTI_DRY_RUN | Set 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
| Field | Value |
|---|---|
| source | rss:{name} — e.g. rss:CERT-FR Alertes |
| title | Feed entry title |
| url | Feed entry link |
| summary | Entry content/description (HTML stripped) |
| published_at | Normalized to UTC |
| tags | Tags defined per feed in config |
| raw | Full feedparser entry object |
Pre-configured feeds (30+)
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
| Field | Value |
|---|---|
| source | ransomware.live |
| title | {victim} by {group} |
| tags | ["ransomware"] |
| raw.group_name | Ransomware group name |
| raw.country | Victim country (ISO code) |
| raw.activity | Group activity status |
| raw.post_url | Leak site post URL |
| raw.description | Victim description |
| raw.discovered | Discovery date |
| raw.published | Publication 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
| Field | Value |
|---|---|
| source | red.flag.domains |
| title | Red Flag Domains – YYYY-MM-DD |
| summary | Full domain list (newline-separated) |
| tags | ["domains"] |
| raw.file | Downloaded filename |
| raw.count | Total domain count |
| raw.date | List 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: 1000between 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]
| Option | Type | Description |
|---|---|---|
| --config | PATH | Path to config.yaml. |
| --connectors | PATH | Path to connectors.yaml. |
| --loop | flag | Run in loop mode (uses scheduler.interval_seconds). |
| --interval | INT | Override loop interval in seconds. |
| --sources | TEXT | Comma-separated source filter. E.g. "rss:,ransomware.live". |
| --dry-run | flag | Log delivery actions without sending anything. |
| --verbose | flag | Set log level to DEBUG. |
| --since | TEXT | Only process events after this date (ISO8601 or YYYY-MM-DD). |
| --no-dedupe | flag | Skip 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
| File | Use case |
|---|---|
| templates/rss_default.j2 | Standard single RSS event (source, summary, link) |
| templates/discord_default.j2 | Discord-optimized layout with >>> quote blocks |
| templates/ransomware_card.j2 | Rich ransomware alert (group, country, activity, dates) |
| templates/domains_list.j2 | Malicious domain list with 50-domain preview and full link |
| templates/batch_default.j2 | Batched multi-event summary with emoji per event |
Template context variables
| Variable | Type | Description |
|---|---|---|
| title | str | Event title |
| source | str | Source ID (e.g. rss:CERT-FR) |
| summary | str | Event body text |
| url | str | None | Event URL |
| emoji | str | Computed emoji for this source/event |
| events | list[Event] | All events in the current batch |
| raw | dict | Raw 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.
| Metric | Type | Labels | Description |
|---|---|---|---|
| cassandra_cti_events_sent | Counter | route | Events successfully delivered. |
| cassandra_cti_fetch_total | Counter | source, status | Fetch attempts per source (ok or err). |
Prometheus scrape config
scrape_configs:
- job_name: "cassandra-cti"
static_configs:
- targets: ["cassandra-cti:9108"]
Architecture
Module map
| Module | Responsibility |
|---|---|
| cli.py | Typer CLI — all user-facing commands |
| main.py | Core pipeline orchestration (fetch → filter → route → send) |
| models.py | Event dataclass |
| config.py | YAML loading, env substitution, Settings / RouteDef / TransportDef |
| config_schema.py | Pydantic schema validation for config.yaml |
| router.py | Route matching (source / tag / regex) |
| store.py | SQLite deduplication and delivery tracking |
| emoji.py | Emoji selection logic (source map + country detection) |
| util.py | expand_env(), canon() URL normalizer, make_event_id() SHA1 |
| sources/rss.py | RSS/Atom feed fetcher |
| sources/ransomware_live.py | ransomware.live JSON fetcher |
| sources/redflag.py | Red Flag Domains daily list scraper |
| transports/teams.py | Microsoft Teams MessageCard transport |
| transports/discord.py | Discord Embed transport |
| transports/__init__.py | Transport 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.