Skip to content
Merged
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
14 changes: 8 additions & 6 deletions scripts/artifacts/discordAccount.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@
"client itself recorded about the machine it was running "
"on. They identify the installation that the other "
"artifacts in this category were parsed from. The "
"time zone recorded here is the one the log-derived "
"artifacts (Channel Navigation, Gateway Sessions) must "
"be read against, since those are written in device "
"local time. The "
"time zone recorded here is the time zone the client "
"recorded when this file was last written; it is the "
"best available reference for the log-derived "
"artifacts (Channel Navigation, Gateway Sessions), "
"which are written in device local time, but the host "
"time zone may have changed over the log's span. The "
"Sentry crash-reporting scope is the richest single "
"source: it names the account, the app build and the "
"hardware, and it is written on every run.",
"hardware.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Reads sentry/scope_v3.json, settings.json, Preferences, "
Expand Down
9 changes: 5 additions & 4 deletions scripts/artifacts/discordCacheRecords.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
__artifacts_v2__ = {
"discordCacheRecords": {
"name": "Discord Cache Records",
"description": "Index of every response held in the Discord Desktop "
"HTTP cache, with the time the client requested it and "
"the time the response was stored. Discord has no "
"description": "Index of the cached responses held in the Discord "
"Desktop HTTP cache, excluding versioned application "
"bundle assets, with the time the client requested each "
"one and the time the response was stored. Discord has no "
"browsing history database, so this index is the closest "
"equivalent: it shows which API calls, CDN images, "
"embedded links and third-party resources the client "
Expand All @@ -14,7 +15,7 @@
"rather than a complete one.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Versioned application bundle assets (js, css, fonts, icons) "
Expand Down
51 changes: 31 additions & 20 deletions scripts/artifacts/discordContacts.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
__artifacts_v2__ = {
"discordUsers": {
"name": "Discord Users Seen",
"description": "Every Discord account observed anywhere in the cached "
"application data: message authors, mentioned users, "
"direct message recipients, people who reacted to a "
"message, invite creators and cached profiles. User IDs "
"are snowflakes, so each account's registration date is "
"description": "Every Discord account seen in the cached responses this "
"parser decodes: message authors, mentioned users, DM "
"recipients, reaction users, profiles and invite "
"creators. User IDs are snowflakes, so each account's "
"registration date is "
"recoverable, and a cached avatar is embedded where one "
"survives. Where a profile response was cached, the "
"external accounts Discord recorded as connected to it "
Expand All @@ -15,13 +15,16 @@
"account's activity window.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Profile fields (bio, pronouns, connected accounts) are only "
"present where a profile response was cached, so a sparse row "
"means no profile response survives, not that the account has "
"no profile.",
"no profile. "
"Reference: Discord Developer Documentation, "
"'Snowflakes (ID format)', "
"https://discord.com/developers/docs/reference#snowflakes",
"paths": (
'*/discord*/Cache/Cache_Data/*_0',
'*/discord*/Service Worker/CacheStorage/*/*/*_0',
Expand All @@ -47,15 +50,18 @@
"covers only the recovered messages.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Channel names and topics are only known where the client "
"cached a channel object or a search response describing them; "
"otherwise only the ID is reported. The server a channel "
"belongs to is resolved from cached channel objects, the "
"renderer log's routing entries and the Local Storage channel "
"selection state.",
"selection state. "
"Reference: Discord Developer Documentation, "
"'Snowflakes (ID format)', "
"https://discord.com/developers/docs/reference#snowflakes",
"paths": (
'*/discord*/Cache/Cache_Data/*_0',
'*/discord*/Service Worker/CacheStorage/*/*/*_0',
Expand Down Expand Up @@ -84,13 +90,16 @@
"for a server that was never joined.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Member counts are the approximate values Discord returned at "
"the time the response was cached, not current figures. "
"Channel and message counts are limited to what the cache and "
"the navigation log revealed about each server.",
"the navigation log revealed about each server. "
"Reference: Discord Developer Documentation, "
"'Snowflakes (ID format)', "
"https://discord.com/developers/docs/reference#snowflakes",
"paths": (
'*/discord*/Cache/Cache_Data/*_0',
'*/discord*/Service Worker/CacheStorage/*/*/*_0',
Expand All @@ -106,22 +115,24 @@
},
"discordInvites": {
"name": "Discord Invites",
"description": "Server invite links the client looked up. Discord "
"resolves an invite code through the API before showing "
"the join prompt, and that response names the server, the "
"channel the invite points at, who created it and when "
"it expires. A row records that the client resolved that "
"invite code and what Discord returned for it. It does "
"not establish that the user joined the server.",
"description": "Server invite links the client looked up. A cached "
"`/invites/<code>` response records that the client "
"resolved that invite code and what Discord returned: "
"the server, the channel the invite points at, who "
"created it and when it expires. It does not establish "
"that the user joined the server.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "One row per invite code, from the most recent cached lookup. "
"The expiry is the value Discord returned when the code was "
"resolved, so an expired invite may still have been valid when "
"it was used.",
"it was used. "
"Reference: Discord Developer Documentation, "
"'Snowflakes (ID format)', "
"https://discord.com/developers/docs/reference#snowflakes",
"paths": (
'*/discord*/Cache/Cache_Data/*_0',
'*/discord*/Service Worker/CacheStorage/*/*/*_0',
Expand Down
32 changes: 22 additions & 10 deletions scripts/artifacts/discordLocalStorage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
"description": "Message drafts held in Local Storage. Discord saves the "
"draft box as text is typed, and Local Storage is a "
"LevelDB, so superseded versions of the key stay on "
"disk. The result is a keystroke-level history of text "
"as it was composed, each version with its own "
"timestamp and target channel. A row records what was in "
"disk. The result is successive saved versions of the "
"draft text, each with its own stored timestamp and "
"target channel. A row records what was in "
"the compose box at that time; whether it was ever sent "
"cannot be determined from this artifact alone.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Rows come from every surviving version of the DraftStore key, "
Expand All @@ -29,21 +29,27 @@
"name": "Discord Client Activity",
"description": "Application usage reconstructed from the Local Storage "
"state Discord keeps between runs: channels opened and "
"when, servers selected, voice channels joined, quick "
"switcher history and client session heartbeats. This is "
"when, servers selected, the selected voice channel, "
"quick switcher history and client session heartbeats. "
"This is "
"usage state that exists independently of any message "
"content: it records when the client opened channels, "
"selected servers and started sessions, whether or not "
"any message from those channels was cached.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Channel-open events come from the frecency store, which keeps "
"a rolling window of recent usages; older versions of the key "
"extend that window further back. Entries without a stored "
"timestamp are reported in order with no time.",
"timestamp are reported in order with no time. The 'Selected "
"voice channel (state)' row pairs the store's "
"lastConnectedTime with its selectedVoiceChannelId: both are "
"co-resident fields of one rolling state object, so the "
"pairing is inferred rather than a recorded association "
"between that time and that channel.",
"paths": ('*/discord*/Local Storage/leveldb/*',),
"output_types": ["html", "tsv", "timeline", "lava"],
"artifact_icon": "activity",
Expand Down Expand Up @@ -204,8 +210,14 @@ def add(timestamp, event, target, detail, record):
elif record.key == "SelectedChannelStore":
connected = state.get("lastConnectedTime") if isinstance(state, dict) else None
if state.get("selectedVoiceChannelId"):
add(discord_api.epoch_ms_to_datetime(connected), "Voice channel connected",
str(state["selectedVoiceChannelId"]), "", record)
# lastConnectedTime and selectedVoiceChannelId are independent
# members of one rolling state object. The store does not record
# that this time belongs to this channel, so the row is labelled
# as state rather than as a join event.
add(discord_api.epoch_ms_to_datetime(connected),
"Selected voice channel (state)",
str(state["selectedVoiceChannelId"]),
"timestamp is the store's lastConnectedTime", record)
for guild_id, channel_id in (state.get("selectedChannelIds") or {}).items():
add("", "Last channel for server", str(channel_id),
f"server {guild_id}", record)
Expand Down
20 changes: 11 additions & 9 deletions scripts/artifacts/discordMedia.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
__artifacts_v2__ = {
"discordRecoveredMedia": {
"name": "Discord Recovered Media",
"description": "Every Discord image, video, avatar, emoji, sticker and "
"server icon still held in the application cache, "
"extracted and embedded in the report. Attachment URLs "
"carry the channel ID and an attachment snowflake, so a "
"description": "Every cached file this parser could identify as Discord "
"media and decode, extracted and embedded in the report: "
"images, video, avatars, emoji, stickers and server "
"icons. Attachment URLs carry the channel ID and an "
"attachment snowflake, so a "
"cached file can be tied to its channel and dated even "
"when the message that carried it is long gone. The "
"'Message Recovered' column flags files whose "
Expand All @@ -13,11 +14,12 @@
"file here does not indicate it was never present.",
"author": "@AlexisBrignoni",
"creation_date": "2026-07-26",
"last_update_date": "2026-07-26",
"last_update_date": "2026-08-01",
"requirements": "none",
"category": "Discord (macOS)",
"notes": "Discord serves resized WebP copies to the client, so "
"recovered bytes are often a transcode rather than the "
"notes": "The served content type is frequently WebP rather than the "
"uploaded type, so recovered bytes are often a transcode "
"rather than the "
"original upload and will not necessarily hash to the file as "
"it was uploaded. One "
"row per cached file: the same image appears more than once "
Expand Down Expand Up @@ -83,8 +85,8 @@ def discordRecoveredMedia(context):

content_type = (media.get("content_type") or "").split(";")[0].strip()
filename = discord_api.attachment_filename(media["url"])
# Prefer the served type for images and video (Discord transcodes to
# WebP), otherwise keep the extension the file was uploaded with.
# Prefer the served type for images and video (frequently WebP rather
# than the uploaded type), otherwise keep the uploaded extension.
if content_type.startswith(("image/", "video/", "audio/")):
extension = content_type.split("/")[-1]
else:
Expand Down
Loading
Loading