Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions server/thumper/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,65 @@ class DeliveryAttempt(Base):
)


class HoneytokenConnection(Base):
"""A connection to a third-party SaaS platform (Datadog, Salesforce, AWS)
in which honeytokens are created and whose audit log is polled for use."""
__tablename__ = "honeytoken_connections"
id = Column(String(255), primary_key=True)
name = Column(String(255), nullable=False)
plugin = Column(String(255), nullable=False)
config_json = Column(Text, nullable=False, default="{}")
configured = Column(Boolean, nullable=False, default=False)
last_poll_at = Column(String(255))
created_at = Column(String(255), nullable=False)


class Honeytoken(Base):
"""A fake credential created on a SaaS platform via a HoneytokenConnection.
`state`: pending -> active -> triggered. `token_id`/`token_type` are the
platform's identifiers; a use of it (seen in the audit log) fires."""
__tablename__ = "honeytokens"
id = Column(String(255), primary_key=True)
connection_id = Column(
String(255),
ForeignKey("honeytoken_connections.id", ondelete="CASCADE"),
nullable=False,
)
name = Column(String(255), nullable=False)
token_id = Column(String(255), nullable=False)
token_type = Column(String(255), nullable=False)
metadata_json = Column(Text, nullable=False, default="{}")
state = Column(String(255), nullable=False, default="pending")
created_at = Column(String(255), nullable=False)
last_used_at = Column(String(255))
__table_args__ = (
Index("ix_ht_connection", "connection_id"),
)


class HoneytokenUsageLog(Base):
"""One recorded use of a honeytoken, seen in the platform's audit log.
`(honeytoken_id, event_id)` is unique so re-polling the same audit window
never double-records or double-alerts a single use."""
__tablename__ = "honeytoken_usage_logs"
id = Column(String(255), primary_key=True)
honeytoken_id = Column(
String(255),
ForeignKey("honeytokens.id", ondelete="CASCADE"),
nullable=False,
)
event_id = Column(String(255))
actor = Column(String(255))
source_ip = Column(String(255))
action = Column(String(255))
timestamp = Column(String(255), nullable=False)
created_at = Column(String(255), nullable=False)
__table_args__ = (
Index("ix_usage_log_honeytoken", "honeytoken_id"),
UniqueConstraint("honeytoken_id", "event_id", name="uq_honeytoken_usage_event"),
)


# ── engine + session ────────────────────────────────────────────────────────
# Created lazily on first use rather than at import time, so a THUMPER_DB / dotenv
# override applied after this module is imported (CLI, tests) still takes effect.
Expand Down
69 changes: 69 additions & 0 deletions server/thumper/migrations/versions/honeytoken_tables_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Add honeytoken tables for third-party SaaS canary credentials.

Revision ID: honeytoken_tables_v1
Revises: endpoint_ephemeral_v1
Create Date: 2026-07-21
"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "honeytoken_tables_v1"
down_revision: Union[str, None] = "endpoint_ephemeral_v1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"honeytoken_connections",
sa.Column("id", sa.String(255), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("plugin", sa.String(255), nullable=False),
sa.Column("config_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("configured", sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column("last_poll_at", sa.String(255), nullable=True),
sa.Column("created_at", sa.String(255), nullable=False),
)
op.create_table(
"honeytokens",
sa.Column("id", sa.String(255), primary_key=True),
sa.Column("connection_id", sa.String(255),
sa.ForeignKey("honeytoken_connections.id", ondelete="CASCADE"),
nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("token_id", sa.String(255), nullable=False),
sa.Column("token_type", sa.String(255), nullable=False),
sa.Column("metadata_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("state", sa.String(255), nullable=False, server_default="pending"),
sa.Column("created_at", sa.String(255), nullable=False),
sa.Column("last_used_at", sa.String(255), nullable=True),
)
op.create_index("ix_ht_connection", "honeytokens", ["connection_id"])
op.create_table(
"honeytoken_usage_logs",
sa.Column("id", sa.String(255), primary_key=True),
sa.Column("honeytoken_id", sa.String(255),
sa.ForeignKey("honeytokens.id", ondelete="CASCADE"),
nullable=False),
sa.Column("event_id", sa.String(255), nullable=True),
sa.Column("actor", sa.String(255), nullable=True),
sa.Column("source_ip", sa.String(255), nullable=True),
sa.Column("action", sa.String(255), nullable=True),
sa.Column("timestamp", sa.String(255), nullable=False),
sa.Column("created_at", sa.String(255), nullable=False),
sa.UniqueConstraint("honeytoken_id", "event_id",
name="uq_honeytoken_usage_event"),
)
op.create_index("ix_usage_log_honeytoken", "honeytoken_usage_logs",
["honeytoken_id"])


def downgrade() -> None:
op.drop_index("ix_usage_log_honeytoken", table_name="honeytoken_usage_logs")
op.drop_table("honeytoken_usage_logs")
op.drop_index("ix_ht_connection", table_name="honeytokens")
op.drop_table("honeytokens")
op.drop_table("honeytoken_connections")
45 changes: 45 additions & 0 deletions server/thumper/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
not as separate arguments.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field

from pydantic import BaseModel

Expand Down Expand Up @@ -74,3 +75,47 @@ def test(self) -> None:
"endpoint_hostname": "thumper-server",
"message": "Thumper test event - your integration is wired up correctly.",
})


@dataclass
class TokenUsageEvent:
"""A detected usage of a honeytoken on a SaaS platform, parsed from that
platform's activity/audit log by a HoneytokenPlugin's poll_usage()."""
token_id: str
timestamp: str
actor: str | None = None
source_ip: str | None = None
action: str | None = None
extra: dict = field(default_factory=dict)


class HoneytokenPlugin(ABC):
"""Create fake credentials ("honeytokens") on third-party SaaS platforms and
poll each platform's audit log for any use of them. A plugin lives under
plugins/honeytoken/<name>/ (manifest.yaml + plugin.py with class `Plugin`)."""

def __init__(self, config: dict):
self.config = config or {}

@abstractmethod
def connect(self) -> None:
"""Authenticate and verify connectivity. Raise PluginError on failure."""

@abstractmethod
def create_token(self, name: str, options: dict | None = None) -> dict:
"""Create a honeytoken on the platform. Returns a dict with at least
{"token_id": "...", "token_type": "..."} (plus any metadata to persist)."""

@abstractmethod
def revoke_token(self, token_id: str) -> None:
"""Revoke/delete the honeytoken on the platform. Raise on failure."""

@abstractmethod
def poll_usage(self, token_ids: list[str],
since: str | None = None) -> list[TokenUsageEvent]:
"""Return any usage of the given tokens seen in the platform's audit log
since `since` (an ISO-8601 cursor, or None for the plugin's default)."""

def test(self) -> None:
"""Verify connectivity (default: calls connect). Plugins may override."""
self.connect()
2 changes: 1 addition & 1 deletion server/thumper/plugins/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from ..config import PLUGINS_DIR

_KINDS = ("deploy", "alert")
_KINDS = ("deploy", "alert", "honeytoken")

_manifest_cache: list[dict] | None = None
_module_cache: dict[str, object] = {}
Expand Down
155 changes: 154 additions & 1 deletion server/thumper/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
the app deals in ORM model instances (attribute access: row.id, row.name, …).
"""
import hmac
import json
import secrets
from datetime import datetime, timezone
from typing import Optional
Expand All @@ -11,7 +12,8 @@
from sqlalchemy.orm import Session

from .db import (
Alert, Deployment, DeliveryAttempt, Endpoint, Integration, Tripwire,
Alert, Deployment, DeliveryAttempt, Endpoint, Honeytoken,
HoneytokenConnection, HoneytokenUsageLog, Integration, Tripwire,
)
from .models import iso_now
from .services.secrets_crypto import pack_config
Expand Down Expand Up @@ -432,3 +434,154 @@ def set_integration_test_result(db: Session, *, plugin: str, status: str,
row.last_test_at = iso_now()
row.last_test_error = error
db.commit()


# ── honeytoken connections ───────────────────────────────────────────────────
def create_honeytoken_connection(db: Session, *, name: str, plugin: str,
config: dict) -> HoneytokenConnection:
row = HoneytokenConnection(id=_id("htc"), name=name, plugin=plugin,
config_json=json.dumps(config), configured=False,
created_at=iso_now())
db.add(row)
db.commit()
db.refresh(row)
return row


def list_honeytoken_connections(db: Session) -> list[HoneytokenConnection]:
return db.query(HoneytokenConnection).order_by(
HoneytokenConnection.created_at.desc()).all()


def get_honeytoken_connection(db: Session, hid: str) -> Optional[HoneytokenConnection]:
return db.query(HoneytokenConnection).filter(
HoneytokenConnection.id == hid).first()


def update_honeytoken_connection(db: Session, hid: str, *, name: str,
config: dict) -> Optional[HoneytokenConnection]:
row = get_honeytoken_connection(db, hid)
if row is None:
return None
row.name = name
row.config_json = json.dumps(config)
db.commit()
db.refresh(row)
return row


def delete_honeytoken_connection(db: Session, hid: str) -> bool:
row = get_honeytoken_connection(db, hid)
if row is None:
return False
# SQLite doesn't enforce ON DELETE CASCADE unless the FK pragma is on, so
# remove the children explicitly (their usage logs cascade off the tokens).
for ht in db.query(Honeytoken).filter(Honeytoken.connection_id == hid).all():
db.query(HoneytokenUsageLog).filter(
HoneytokenUsageLog.honeytoken_id == ht.id).delete()
db.delete(ht)
db.delete(row)
db.commit()
return True


def set_honeytoken_connection_test(db: Session, *, hid: str,
configured: bool) -> None:
row = get_honeytoken_connection(db, hid)
if row:
row.configured = configured
db.commit()


def update_honeytoken_last_poll(db: Session, hid: str) -> None:
db.query(HoneytokenConnection).filter(
HoneytokenConnection.id == hid).update(
{HoneytokenConnection.last_poll_at: iso_now()})
db.commit()


# ── honeytokens ──────────────────────────────────────────────────────────────
def create_honeytoken(db: Session, *, connection_id: str, name: str,
token_id: str, token_type: str,
metadata: dict) -> Honeytoken:
row = Honeytoken(id=_id("ht"), connection_id=connection_id, name=name,
token_id=token_id, token_type=token_type,
metadata_json=json.dumps(metadata), state="pending",
created_at=iso_now())
db.add(row)
db.commit()
db.refresh(row)
return row


def list_honeytokens(db: Session) -> list[Honeytoken]:
return db.query(Honeytoken).order_by(Honeytoken.created_at.desc()).all()


def get_honeytoken(db: Session, htid: str) -> Optional[Honeytoken]:
return db.query(Honeytoken).filter(Honeytoken.id == htid).first()


def list_honeytokens_for_connection(db: Session, hid: str) -> list[Honeytoken]:
return db.query(Honeytoken).filter(Honeytoken.connection_id == hid).all()


def list_active_honeytokens_for_connection(db: Session,
hid: str) -> list[Honeytoken]:
return db.query(Honeytoken).filter(
Honeytoken.connection_id == hid,
Honeytoken.state.in_(["active", "triggered"])).all()


def set_honeytoken_state(db: Session, htid: str, state: str) -> None:
db.query(Honeytoken).filter(Honeytoken.id == htid).update(
{Honeytoken.state: state})
db.commit()


def mark_honeytoken_used(db: Session, htid: str) -> None:
db.query(Honeytoken).filter(Honeytoken.id == htid).update(
{Honeytoken.last_used_at: iso_now(), Honeytoken.state: "triggered"})
db.commit()


def delete_honeytoken(db: Session, htid: str) -> bool:
row = get_honeytoken(db, htid)
if row is None:
return False
db.query(HoneytokenUsageLog).filter(
HoneytokenUsageLog.honeytoken_id == htid).delete()
db.delete(row)
db.commit()
return True


def record_honeytoken_usage(db: Session, htid: str, event_id: Optional[str],
actor: Optional[str], source_ip: Optional[str],
action: Optional[str],
timestamp: str) -> Optional[HoneytokenUsageLog]:
"""Record one use of a honeytoken. Returns None (recording nothing) when
`event_id` is set and already logged for this token, so a re-poll of the
same audit window neither double-records nor re-alerts a single use."""
if event_id:
existing = db.query(HoneytokenUsageLog).filter(
HoneytokenUsageLog.honeytoken_id == htid,
HoneytokenUsageLog.event_id == event_id,
).first()
if existing:
return None
row = HoneytokenUsageLog(
id=_id("hul"), honeytoken_id=htid, event_id=event_id, actor=actor,
source_ip=source_ip, action=action, timestamp=timestamp,
created_at=iso_now(),
)
db.add(row)
db.commit()
db.refresh(row)
return row


def list_honeytoken_usage_logs(db: Session, htid: str) -> list[HoneytokenUsageLog]:
return db.query(HoneytokenUsageLog).filter(
HoneytokenUsageLog.honeytoken_id == htid,
).order_by(HoneytokenUsageLog.timestamp.desc()).all()
Loading