<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Neo4j | Jacob Larsen</title><link>https://larsencyber.com/tag/neo4j/</link><atom:link href="https://larsencyber.com/tag/neo4j/index.xml" rel="self" type="application/rss+xml"/><description>Neo4j</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><image><url>https://larsencyber.com/media/logo.svg</url><title>Neo4j</title><link>https://larsencyber.com/tag/neo4j/</link></image><item><title>Constellation: Clustering Nihilistic Violent Extremist Telegram Networks</title><link>https://larsencyber.com/blog/2026-06-19-constellation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://larsencyber.com/blog/2026-06-19-constellation/</guid><description>&lt;p>A technical account of a pipeline I built to collect Telegram entity data and model it as a node graph in Neo4j, supporting indicator clustering for threat actor investigations.&lt;/p>
&lt;h2 id="foreword">Foreword&lt;/h2>
&lt;p>This post describes a private research tool I created, &lt;em>&amp;ldquo;Constellation&amp;rdquo;&lt;/em>, and the methodology behind it. It deliberately contains no target identifiers, no victim data, and no live findings. Collection of this kind should only be conducted under appropriate authorisation and legal advice, against a defined investigative scope with source material handled as sensitive evidence. The intent throughout is to support lawful disruption of threat groups and the protection of victims.&lt;/p>
&lt;p>A note on the graphs below. The interactive node graphs embedded throughout this post are &lt;strong>representations&lt;/strong> of Constellation&amp;rsquo;s output, rebuilt as self-contained visualisations for the web. They are not the live tool, and they use mock data throughout. The entities, names and handles in them are all fictional, arranged to mirror the structure of a real investigation without exposing any real target.&lt;/p>
&lt;h2 id="background">Background&lt;/h2>
&lt;p>A significant part of my threat intelligence work over the past four years has concerned a threat group known as &lt;strong>The Com&lt;/strong>, short for &amp;ldquo;The Community&amp;rdquo;. The Com is a loosely connected, predominantly English-speaking group of minors involved in a wide range of criminal activity, from hacking, to physical attacks and sexual extortion. The FBI released an &lt;a href="https://www.ic3.gov/PSA/2025/PSA250723-3" target="_blank" rel="noopener">alert&lt;/a> on The Com in July 2025 which has significant detail on the group&amp;rsquo;s crimes, motivations and subsets.&lt;/p>
&lt;p>One subset of this threat group is called &lt;strong>Extortion Com&lt;/strong> which is as a &lt;a href="https://www.justice.gov/opa/pr/leaders-764-arrested-and-charged-operating-global-child-exploitation-enterprise" target="_blank" rel="noopener">nihilistic violent extremist network&lt;/a>. Threat actors in Extortion Com engage in the most depraved and evil acts, such as threats, blackmail, and manipulation to coerce or extort victims into performing acts of self-harm, violence, and animal cruelty. Extortion Com is fragmented into dozens of subgroups, with thousands of members.&lt;/p>
&lt;p>These groups do not operate from a single, stable online location. They are distributed across thousands of Telegram channels, groups and disposable accounts that are created, abandoned and rebranded continuously. When a channel is reported and removed, equivalents reappear shortly afterwards. Investigating any single account or channel in isolation yielded very little. The information that matters is &lt;strong>relational&lt;/strong>: which threat actor aliases operate which accounts, which accounts administer which channels, and which accounts recur across channels that otherwise appear unconnected.&lt;/p>
&lt;p>This is the investigation problem that Constellation was built to address. It is not a scraping tool, it is a pipeline for turning Telegram entity data into a graph that an analyst can query, so that the structure of a network, rather than the content of any particular message, becomes the unit of analysis.&lt;/p>
&lt;p>This post describes how it works end-to-end, and the two intelligence techniques it was designed to support: &lt;strong>pivoting&lt;/strong> and &lt;strong>clustering&lt;/strong>.&lt;/p>
&lt;h2 id="why-a-node-graph">Why a node graph?&lt;/h2>
&lt;p>The questions that arise during a threat actor investigation are almost all questions about relationships:&lt;/p>
&lt;ul>
&lt;li>Which accounts administer more than one channel within a given set?&lt;/li>
&lt;li>Two channel present as unrelated - do they share administrators or members?&lt;/li>
&lt;li>A newly observed account: has it co-administered a channel already attributed to a known group?&lt;/li>
&lt;li>When was a channel created, and which others were established in the same period?&lt;/li>
&lt;/ul>
&lt;p>Relational tables answer these questions poorly. A property graph answers them directly because the relationships are first-class objects in the model. The architecture therefore follows the questions: collect the entities, represent them as nodes and edges, and query the graph.&lt;/p>
&lt;p>The underlying technique is &lt;strong>clustering&lt;/strong>, grouping multiple individually weak indicators (a profile name, communication nuances, channel memberships, group rosters), until, taken together, they support an attribution with materially higher confidence than any single indicator would. Not a single data point is used for identification. The combination is what carries weight.&lt;/p>
&lt;p>Constellation has three stages.
&lt;img src="https://larsencyber.com/img/fig-pipeline.png" alt="…" style="max-height:1000px;width:auto;display:block;margin-inline:auto;">
&lt;em>Figure 1: The collection plane feeds a normalised relational store, which is ingested into a Neo4j property graph for analysis.&lt;/em>&lt;/p>
&lt;h3 id="stage-1---collection">Stage 1 - Collection&lt;/h3>
&lt;p>The collector is built on &lt;a href="https://docs.telethon.dev/" target="_blank" rel="noopener">Telethon&lt;/a>, the asyncio MTProto client for Telegram. Operating at the MTProto layer, as a user client rather than through the Bot API, was a deliberate choice. This is because a user client observes the network as a member does, with access to dialogs, participant lists and administrative metadata that the Bot API does not expose.&lt;/p>
&lt;p>Authentication uses QR-code login by default, with a phone-number fallback and two-factor handling where required. The session is persisted so that collection can resume without re-authenticating. The account used for collection is itself an indicator and is treated accordingly: dedicated, isolated, and never reused across contexts. Collection is not limited to a single account either. Constellation can load a small pool of accounts, each with its own credentials and session, authenticate them independently, and rotate between them with a configurable delay, all writing into one shared store. This spreads the work of a long collection across several identities, without leaning too heavily on any one of them.&lt;/p>
&lt;p>Once authenticated, the discovery routine iterates the dialogs the account can see and classifies each entity as a channel, group, supergroup, megagroup or user. Telegram&amp;rsquo;s own service channel is excluded, and discovery runs concurrently behind a bounded apparatus so that large dialog lists do not serialise.&lt;/p>
&lt;p>
&lt;figure id="figure-figure-2-the-collector-adapts-to-access-level-and-respects-telegrams-rate-limits">
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Collection flow: authenticate, iterate and classify dialogs, then for each entity either pull the full participant list or, where access is restricted, reconstruct presence from message history; results are written as account, channel and relationship records, with FloodWaitError handled by backing off." srcset="
/blog/2026-06-19-constellation/content/fig-collection_huf16a0085d5d7bb8979c6ecf6c4095363_256764_cd7d059a18596aa36fe0b681dea21e6d.webp 400w,
/blog/2026-06-19-constellation/content/fig-collection_huf16a0085d5d7bb8979c6ecf6c4095363_256764_307f5912b68fc82de4f8720b56983bcc.webp 760w,
/blog/2026-06-19-constellation/content/fig-collection_huf16a0085d5d7bb8979c6ecf6c4095363_256764_1200x1200_fit_q85_h2_lanczos_3.webp 1200w"
src="https://larsencyber.com/blog/2026-06-19-constellation/content/fig-collection_huf16a0085d5d7bb8979c6ecf6c4095363_256764_cd7d059a18596aa36fe0b681dea21e6d.webp"
width="552"
height="760"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;figcaption>
Figure 2: The collector adapts to access level and respects Telegram&amp;rsquo;s rate limits.
&lt;/figcaption>&lt;/figure>
&lt;/p>
&lt;p>Extraction produces three record types:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Accounts&lt;/strong> - ID, username, display name, the bot flag, and verified and premium status, with a fuller profile retrieved where the API permits.&lt;/li>
&lt;li>&lt;strong>Channels&lt;/strong> - ID, title, username and URL, member count, entity type, privacy status, and the &lt;code>is_verified&lt;/code>, &lt;code>is_scam&lt;/code> and &lt;code>is_fake&lt;/code> flags Telegram itself assigns.&lt;/li>
&lt;li>&lt;strong>Relationships&lt;/strong> - the account-to-channel edges. Participant data is mapped to a permission level: &lt;code>ChannelParticipantCreator&lt;/code> to &lt;strong>OWNER&lt;/strong>, &lt;code>ChannelParticipantAdmin&lt;/code> to &lt;strong>ADMIN&lt;/strong>, and other participants to &lt;strong>MEMBER&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>For administrators and owners of channels, the individual administrative rights are preserved as discrete fields (e.g. the ability to delete messages, restrict members, promote other administrators, or change channel information, alongside any other custom rank). This granularity is significant: an account able to promote administrators across several channels occupies a different position of trust than one able only to post, and that distinction becomes useful during analysis.&lt;/p>
&lt;p>Where a channel restricts its participant list, the collector reconstructs membership from message history, recording a membership edge for each distinct account that has posted. Administrative detail is lost in this mode, but presence is retained. Presence across multiple otherwise unconnected channels is itself a clustering signal.&lt;/p>
&lt;p>Telegram&amp;rsquo;s API does not return a channel&amp;rsquo;s creation date. Because that value is useful for clustering, Constellation estimates it from the earliest available message and, where administrative access exists, the earliest admin-log event. The result is treated as an estimate and weighed alongside other indicators rather than relied upon in isolation.&lt;/p>
&lt;p>Aggressive collection is answered by Telegram with &lt;code>FloodWaitError&lt;/code>. The collector honours the server-specified wait interval and backs off rather than retrying immediately. Collection is correspondingly slower, but the account remains usable over the long timeframes these investigations require.&lt;/p>
&lt;h3 id="stage-2---storage">Stage 2 - Storage&lt;/h3>
&lt;p>Collected data is written to &lt;strong>SQLite&lt;/strong> before it reaches the graph. Retaining a normalised relational copy is intentional: it is the source of truth, it exports cleanly as evidence, and it allows the graph to be rebuilt at any time without re-collection.&lt;/p>
&lt;p>The entity schema comprises three core tables:&lt;/p>
&lt;ol>
&lt;li>&lt;code>users&lt;/code>, keyed on account identifier;&lt;/li>
&lt;li>&lt;code>channels&lt;/code>, keyed on channel identifier and including the estimated creation date; and&lt;/li>
&lt;li>&lt;code>relationships&lt;/code>, the account-to-channel edges, with a uniqueness constraint on the account–channel pair so that re-collection updates an existing edge rather than duplicating it.&lt;/li>
&lt;/ol>
&lt;p>A fourth table records the &lt;strong>access level&lt;/strong> held for each entity: full, limited, or none. That provenance is not incidental: in intelligence work, the completeness and confidence of a collection is as important as its content.&lt;/p>
&lt;p>All data exports to CSV and JSON with uniicode normalisation, so that non-Latin channel names survive processing intact.&lt;/p>
&lt;p>Where it runs is a deployment detail, not a design one. Constellation runs equally as a local command-line process or as a containerised service, and one variant packages the collector for Google Cloud Run behind a small HTTP API, writing each run&amp;rsquo;s CSV, JSON and database snapshot out to cloud storage. None of that changes the data model, only how collection is scheduled and where its output lands.&lt;/p>
&lt;h3 id="stage-3---graph-ingestion">Stage 3 - Graph Ingestion&lt;/h3>
&lt;p>The final stage loads the relational data into &lt;strong>Neo4j&lt;/strong> as a property graph. The model is intentionally small: two node labels and three relationship types.&lt;/p>
&lt;p>
&lt;figure id="figure-figure-3-accounts-and-channels-as-nodes-ownership-administration-and-membership-as-typed-property-bearing-edges">
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Graph model: TelegramAccount nodes connect to TelegramChannel nodes through OWNER_OF, ADMIN_OF and MEMBER_OF relationships, each carrying administrative-rights properties." srcset="
/blog/2026-06-19-constellation/content/fig-graph-model_hu4f752e0881b42f57fc01b8a5e2de6495_164063_f3ffdb2a2cbd3e714edd27deb742de95.webp 400w,
/blog/2026-06-19-constellation/content/fig-graph-model_hu4f752e0881b42f57fc01b8a5e2de6495_164063_8bc9208b6ef98b9726c8be356c60f83d.webp 760w,
/blog/2026-06-19-constellation/content/fig-graph-model_hu4f752e0881b42f57fc01b8a5e2de6495_164063_1200x1200_fit_q85_h2_lanczos_3.webp 1200w"
src="https://larsencyber.com/blog/2026-06-19-constellation/content/fig-graph-model_hu4f752e0881b42f57fc01b8a5e2de6495_164063_f3ffdb2a2cbd3e714edd27deb742de95.webp"
width="760"
height="495"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;figcaption>
Figure 3. Accounts and channels as nodes; ownership, administration and membership as typed, property-bearing edges.
&lt;/figcaption>&lt;/figure>
&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">(:TelegramAccount {user_id, username, version, ...})
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">(:TelegramChannel {channel_id, channel_title, creation_date, version, ...})
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">(:TelegramAccount)-[:OWNER_OF {admin_rights, ...}]-&amp;gt;(:TelegramChannel)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">(:TelegramAccount)-[:ADMIN_OF {admin_rights, ...}]-&amp;gt;(:TelegramChannel)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">(:TelegramAccount)-[:MEMBER_OF {...}]-&amp;gt;(:TelegramChannel)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Uniqueness constraints on &lt;code>TelegramAccount.user_id&lt;/code> and &lt;code>TelegramChannel.channel_id&lt;/code>, together with indexes on the username and collection-date fields, are created before loading. They keep the graph free of duplicates and keep queries responsive.&lt;/p>
&lt;p>The ingestor is built to be run repeatedly against a target that keeps changing, so every write is a &lt;code>MERGE&lt;/code> rather than a &lt;code>CREATE&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">UNWIND $batch AS account
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">MERGE (t:TelegramAccount {user_id: account.user_id})
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ON CREATE SET t.version = 1,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> t.created_date = datetime(),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> t += account.props
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ON MATCH SET t.version = t.version + 1,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> t.updated_date = datetime(),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> t += account.props
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first time an account is seen it is created at version one. Every later run that touches it increments the version and updates the timestamp, which gives me a record of &lt;strong>change over time&lt;/strong>. An account whose membership, username or administrative footprint shifts between collections is one that is moving within the network, and that movement is itself worth flagging. Relationships are merged on the same principle, so re-ingestion never produces duplicate edges. Loading is batched, with validation and type coercion applied first, and any record missing a required field is logged and skipped rather than allowed to corrupt the load.&lt;/p>
&lt;p>That is the whole automated model: one account, the channels it touches, and the three typed edges between them. The graph below is that smallest unit running live. Hover or click a node or an edge to inspect the properties each one carries.&lt;/p>
&lt;iframe src="https://larsencyber.com/graphs/graph-model-live.html" style="width:100%; height:480px; border:none; border-radius:8px;" loading="lazy" title="Constellation graph model, live">&lt;/iframe>
&lt;p>&lt;em>A representation of the Constellation graph model, reconstructed for this post and not a live system.&lt;/em>&lt;/p>
&lt;h2 id="from-a-collection-graph-to-an-attribution-graph">From a collection graph to an attribution graph&lt;/h2>
&lt;p>The pipeline above produces a faithful map of accounts and channels. What it does not do, and cannot do, is tell me who is behind them. That is the part no scraper can automate, and it is where the actual intelligence work happens.&lt;/p>
&lt;p>On top of the collected &lt;code>TelegramAccount&lt;/code> and &lt;code>TelegramChannel&lt;/code> data, I maintain a second, manually curated layer in the same Neo4j graph. It introduces node types the collector never creates. An &lt;code>Actor&lt;/code> is a tracked human operator, an &lt;code>Identity&lt;/code> is a real-world person where one has been established, and a &lt;code>ThreatGroup&lt;/code> is the group itself. Alongside these sit account nodes for the other platforms the same actors use, such as Discord, YouTube, Doxbin and TikTok. They are joined by a small, deliberate relationship grammar:&lt;/p>
&lt;ul>
&lt;li>an &lt;code>Actor&lt;/code> is &lt;code>IDENTIFIED_AS&lt;/code> a real-world &lt;code>Identity&lt;/code>;&lt;/li>
&lt;li>an &lt;code>Actor&lt;/code> &lt;code>OPERATES&lt;/code> the accounts attributed to them, across any platform;&lt;/li>
&lt;li>an &lt;code>Actor&lt;/code> is a &lt;code>MEMBER_OF&lt;/code>, &lt;code>FOUNDER_OF&lt;/code>, &lt;code>RECRUITER_OF&lt;/code> or &lt;code>STAFF_OF&lt;/code> a &lt;code>ThreatGroup&lt;/code>;&lt;/li>
&lt;li>&lt;code>ThreatGroup&lt;/code>s are &lt;code>AFFILIATED&lt;/code> with one another; and&lt;/li>
&lt;li>a &lt;code>ThreatGroup&lt;/code> &lt;code>OPERATES&lt;/code> the channels that belong to it.&lt;/li>
&lt;/ul>
&lt;p>Every node in this layer carries its own provenance, recording who created it, when, a confidence level, and a version. This is because in attribution work the basis for a claim matters as much as the claim itself. It is the join between collection and intelligence. The automated graph records which accounts administer which channels, and the attribution layer records what I have concluded about who is behind them, and how confident I am in that conclusion.&lt;/p>
&lt;p>The graph below is a worked example built from mock data. A single tracked actor, here called &amp;ldquo;Driftwood&amp;rdquo;, sits at the centre, connected out to the groups they belong to, an identity behind them, and the accounts they operate across Telegram, Discord and YouTube. One Telegram account, &lt;code>palecedarback&lt;/code>, acts as the operational hub for a cluster of channels. The names are fictional, but the shape is exactly what an attribution graph looks like in practice. Click any node to open its full property and provenance panel.&lt;/p>
&lt;iframe src="https://larsencyber.com/graphs/com-network-graph.html" style="width:100%; height:620px; border:none; border-radius:8px;" loading="lazy" title="Mock threat actor attribution graph">&lt;/iframe>
&lt;p>&lt;em>A worked attribution graph built from mock data, with fictional entities throughout.&lt;/em>&lt;/p>
&lt;h2 id="pivoting-and-clustering">Pivoting and clustering&lt;/h2>
&lt;p>With the network in Neo4j, the analysis can begin. The tool builds the substrate, but the intelligence comes from the questions I ask of it. The Cypher pivots below are the ones I lean on most, and I have paired each one with a small interactive view of the shape it surfaces.&lt;/p>
&lt;p>&lt;strong>Accounts administering multiple channels.&lt;/strong> A single-channel administrator is common and tells me very little. An account administering several is closer to infrastructure, and worth a closer look.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">MATCH (a:TelegramAccount)-[:ADMIN_OF|OWNER_OF]-&amp;gt;(c:TelegramChannel)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WITH a, count(DISTINCT c) AS channels, collect(c.channel_title) AS where_
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WHERE channels &amp;gt; 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">RETURN a.username, channels, where_
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ORDER BY channels DESC
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;iframe src="https://larsencyber.com/graphs/pivot-multi-admin.html" style="width:100%; height:500px; border:none; border-radius:8px;" loading="lazy" title="Pivot: accounts administering multiple channels">&lt;/iframe>
&lt;p>&lt;em>A representation of the multi-channel-admin pivot: one account that owns or administers several channels.&lt;/em>&lt;/p>
&lt;p>&lt;strong>Channels linked by shared administration.&lt;/strong> Two channels that present as unrelated, but are administered by the same accounts, are for attribution purposes a single operation.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">MATCH (c1:TelegramChannel)&amp;lt;-[:ADMIN_OF|OWNER_OF]-(a:TelegramAccount)-[:ADMIN_OF|OWNER_OF]-&amp;gt;(c2:TelegramChannel)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WHERE id(c1) &amp;lt; id(c2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WITH c1, c2, count(DISTINCT a) AS shared_admins, collect(a.username) AS who
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">RETURN c1.channel_title, c2.channel_title, shared_admins, who
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ORDER BY shared_admins DESC
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;iframe src="https://larsencyber.com/graphs/pivot-shared-admin.html" style="width:100%; height:500px; border:none; border-radius:8px;" loading="lazy" title="Pivot: channels linked by shared administration">&lt;/iframe>
&lt;p>&lt;em>A representation of the shared-administration pivot: two channels bridged by the accounts that administer both.&lt;/em>&lt;/p>
&lt;p>&lt;strong>Channels linked by overlapping membership.&lt;/strong> This is a weaker signal than shared administration, but at volume it reliably outlines the satellite channels around a core. The view below is drawn from a mock collection of the same shape: two chatrooms and the accounts that hold membership in both.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">MATCH (c1:TelegramChannel)&amp;lt;-[:MEMBER_OF]-(u:TelegramAccount)-[:MEMBER_OF]-&amp;gt;(c2:TelegramChannel)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WHERE id(c1) &amp;lt; id(c2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WITH c1, c2, count(DISTINCT u) AS shared_members
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">WHERE shared_members &amp;gt; 10
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">RETURN c1.channel_title, c2.channel_title, shared_members
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ORDER BY shared_members DESC
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;iframe src="https://larsencyber.com/graphs/pivot-membership-overlap.html" style="width:100%; height:540px; border:none; border-radius:8px;" loading="lazy" title="Pivot: channels linked by overlapping membership">&lt;/iframe>
&lt;p>&lt;em>A representation built from mock collection data: two channels and a sample of the members they share.&lt;/em>&lt;/p>
&lt;p>&lt;strong>Expansion from a single indicator.&lt;/strong> This is the routine pivot. I begin from one attributed account and expand outwards to the channels it touches, and then to the accounts that touch those channels.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">MATCH (seed:TelegramAccount {username: $known_actor})-[r]-&amp;gt;(c:TelegramChannel)&amp;lt;-[r2]-(neighbour:TelegramAccount)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">RETURN seed, r, c, r2, neighbour
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;iframe src="https://larsencyber.com/graphs/pivot-seed-expansion.html" style="width:100%; height:520px; border:none; border-radius:8px;" loading="lazy" title="Pivot: expansion from a single seed account">&lt;/iframe>
&lt;p>&lt;em>A representation of single-seed expansion: one attributed account, the channels it touches, and the neighbours that appear alongside it.&lt;/em>&lt;/p>
&lt;p>The process is iterative. A surfaced cluster produces new accounts of interest, so I re-run collection around them, re-ingest the graph, and the picture sharpens. I then combine the structural signals with temporal ones, such as channels whose estimated creation dates fall in the same window, or accounts whose version history shows them moving together, so that weak indicators accumulate into a defensible assessment.&lt;/p>
&lt;p>
&lt;figure id="figure-figure-4-clustering-accumulates-independent-indicators-around-a-seed-the-assessment-rests-on-their-combination-not-on-any-single-edge">
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Clustering workflow: from a seed account, expand to administered channels, then co-administrators, then overlapping membership, corroborate with temporal signals to form a candidate cluster, then either re-collect around new accounts or pass the combined indicators to analyst assessment." srcset="
/blog/2026-06-19-constellation/content/fig-clustering_hubf00b748cedc34847e4f4e5210e3161d_266022_fc74ab43370efb6e987bcc27941b96a8.webp 400w,
/blog/2026-06-19-constellation/content/fig-clustering_hubf00b748cedc34847e4f4e5210e3161d_266022_b91f42dc17a7e17ed0751787e8e40cb3.webp 760w,
/blog/2026-06-19-constellation/content/fig-clustering_hubf00b748cedc34847e4f4e5210e3161d_266022_1200x1200_fit_q85_h2_lanczos_3.webp 1200w"
src="https://larsencyber.com/blog/2026-06-19-constellation/content/fig-clustering_hubf00b748cedc34847e4f4e5210e3161d_266022_fc74ab43370efb6e987bcc27941b96a8.webp"
width="380"
height="760"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;figcaption>
Figure 4: Clustering accumulates independent indicators around a seed. The assessment rests on their combination, not on any single edge.
&lt;/figcaption>&lt;/figure>
&lt;/p>
&lt;p>This is the substance of clustering. No single edge in the graph identifies anyone. A username is deniable, an administrative right is deniable, and a creation timestamp can be coincidence. But an account that co-administers several channels alongside an already-attributed account, was established in the same narrow window as those channels, and shares a large proportion of its membership with them, is no longer plausibly a coincidence. The combination is the finding.&lt;/p>
&lt;p>It is worth seeing what this looks like at scale. The graph below is a representation of a single mock collection: roughly 760 accounts across five channels, with a thin attribution layer laid over the top - the groups that operate the channels, and a handful of actors tied to the accounts that administer them, reaching out in turn to identities and to accounts on other platforms. The channels sit as bright hubs, privileged accounts are drawn larger, and the whole graph carries the same node types as the rest of the post. Hover or click any node to inspect it.&lt;/p>
&lt;iframe src="https://larsencyber.com/graphs/com-membership-graph.html" style="width:100%; height:640px; border:none; border-radius:8px;" loading="lazy" title="Mock membership network">&lt;/iframe>
&lt;p>&lt;em>A representation of a mock collection of roughly 760 accounts across five channels, with a mock attribution layer over the top, rendered as a standalone visualisation rather than the live Neo4j graph.&lt;/em>&lt;/p>
&lt;h2 id="handling-and-limitations">Handling and limitations&lt;/h2>
&lt;p>A few constraints are worth stating plainly. Creation dates are estimates, not facts, and I treat them that way. Membership reconstructed from message history understates true membership and omits inactive participants. Clusters are hypotheses to be tested against further evidence, not conclusions, and the deliverable is the analyst&amp;rsquo;s judgement, not the raw query output. Collection scope, retention and access are governed accordingly, and the relational store is handled as sensitive evidence with its provenance attached. Mapping a network is an intelligence function, and doing it responsibly, by minimising collateral collection, protecting victim data, and passing actionable assessments to those positioned to act lawfully, is a requirement rather than an afterthought.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The difficulty in investigating networks like this was never reading their messages. It was holding on to the shape of a structure that fragments faster than I can track it by hand. Constellation does not automate attribution, because nothing can. What it does is collect entities faithfully, model them as a graph, and make pivoting cheap, so that a scattered set of channels and disposable accounts becomes something I can reason about systematically.&lt;/p>
&lt;p>Clustering, in the end, is the discipline of refusing to trust any single indicator while taking seriously what many of them say together. The graph is simply where that reasoning is made explicit.&lt;/p></description></item></channel></rss>