diff --git a/admin/test/results/dleapp_slack_known.json b/admin/test/results/dleapp_slack_known.json new file mode 100644 index 0000000..1cb871c --- /dev/null +++ b/admin/test/results/dleapp_slack_known.json @@ -0,0 +1,189 @@ +{ + "artifacts": { + "Slack Client Sessions": { + "column_digests": { + "duration": "df614dd10f8f78708a22f64bef0e10c82999b33b4511ea807db7a91e1b17e97e", + "last_activity": "f183e34b2df2c07db791d53b11dc5541efebdd62946689e8e160262489108f2d", + "last_logged": "06d42788ae028fd3a77a5aa2b68b2f114561fb872e3921ffa2a28cff88658e89", + "leveldb_sequence": "fad381588576f437403808a2f8edfab5ac31ebe66c17e3cb7620e4303c9842de", + "record_state": "9dc00135cfd673aa4ebc62aedb93358e80a7311025651e5cb93472c64cb51a1c", + "session_id": "dec2a87609c56bf59e3fcef5de7117eb852f8b90da6739cf76615ff3be6250ef", + "session_start": "5274f023dedef9bebfb68c3e68804c99b877678a657d3f9d64f8c8edcc711eff", + "source_file": "5df08bf717df07b4eed3c26f8ec4208c831db3290c16cba4914f4af092671098", + "team_id": "6e1b864f221f80a66db6c6de148472b05d7cab00f3581ab4451d5301e9135bc2" + }, + "columns": [ + { + "name": "team_id", + "type": "text" + }, + { + "name": "session_id", + "type": "text" + }, + { + "name": "session_start", + "type": "datetime" + }, + { + "name": "last_activity", + "type": "datetime" + }, + { + "name": "last_logged", + "type": "datetime" + }, + { + "name": "duration", + "type": "text" + }, + { + "name": "leveldb_sequence", + "type": "text" + }, + { + "name": "record_state", + "type": "text" + }, + { + "name": "source_file", + "type": "text" + } + ], + "datetime_columns": { + "last_activity": { + "in_plausible_range": true, + "parsed": 3 + }, + "last_logged": { + "in_plausible_range": true, + "parsed": 3 + }, + "session_start": { + "in_plausible_range": true, + "parsed": 3 + } + }, + "digest": "ab820abd5e1cb98436bf243ca3346229fd7bc127ed708b791760474e145d3990", + "non_empty": { + "duration": 3, + "last_activity": 3, + "last_logged": 3, + "leveldb_sequence": 3, + "record_state": 3, + "session_id": 3, + "session_start": 3, + "source_file": 3, + "team_id": 3 + }, + "rows": 3 + }, + "Slack Workspaces": { + "column_digests": { + "domain": "f161ba1b9e16a7dc4c7fe6eb7a39ac730a4a0f5e194ba84e732a1e1d0e7a6597", + "last_active": "c38d075e140e0021ec69eced27d3a319d9973ac18b3949cffa8c0425cc492cd2", + "last_viewed_channeldm_id": "c2ba08c7606bcae0cfa982432c5a3a96cd3ecd71c1b190c5abc852819fad2b7f", + "leveldb_sequence": "41ac94fecdba4d8f03673b2e2b56ac38e70b641b389f38e706f137b484736111", + "most_recently_used": "b605bc85de0f9a28efda5b19a85751fa72643f7f56b75e8983d94a03c79ca25d", + "other_fields": "52b6ef9b8e4d27c9db12baf4f940b460dd4f4f58c7faa5886c54764e81e78c4e", + "record_state": "9dc00135cfd673aa4ebc62aedb93358e80a7311025651e5cb93472c64cb51a1c", + "source_file": "5df08bf717df07b4eed3c26f8ec4208c831db3290c16cba4914f4af092671098", + "team_id": "6e1b864f221f80a66db6c6de148472b05d7cab00f3581ab4451d5301e9135bc2", + "team_name": "6efc42cf8fff4c0e5b18fba8e464d867f6e032274310560cc0579490196480a7", + "team_url": "74b013ced8a4193062ae43edf25d3f65265c6c606d324862db428c54bb646c9b", + "token_masked": "b1abc7c7dcbb765e7f6753bf3e7067e6b55ae77318f9c59fbdce287f3f03cc60", + "user_id": "b900a16d873d8f3b8ec7ec94f974cbe11bd1db5ce8f5d8c639f16864c30fa5be", + "version_timestamp": "edda6191124acf47b14fc7fe3936b579d323d10e7f6251080d7a5473b7465301" + }, + "columns": [ + { + "name": "team_id", + "type": "text" + }, + { + "name": "team_name", + "type": "text" + }, + { + "name": "team_url", + "type": "text" + }, + { + "name": "user_id", + "type": "text" + }, + { + "name": "domain", + "type": "text" + }, + { + "name": "most_recently_used", + "type": "datetime" + }, + { + "name": "version_timestamp", + "type": "datetime" + }, + { + "name": "last_viewed_channeldm_id", + "type": "text" + }, + { + "name": "token_masked", + "type": "text" + }, + { + "name": "last_active", + "type": "text" + }, + { + "name": "other_fields", + "type": "text" + }, + { + "name": "leveldb_sequence", + "type": "text" + }, + { + "name": "record_state", + "type": "text" + }, + { + "name": "source_file", + "type": "text" + } + ], + "datetime_columns": { + "most_recently_used": { + "in_plausible_range": true, + "parsed": 3 + }, + "version_timestamp": { + "in_plausible_range": true, + "parsed": 3 + } + }, + "digest": "811342a6275c48b062e58572bcd1d33148e5a7234a168d4b86bdd6bfec7d4c48", + "non_empty": { + "domain": 3, + "last_active": 2, + "last_viewed_channeldm_id": 3, + "leveldb_sequence": 3, + "most_recently_used": 3, + "other_fields": 3, + "record_state": 3, + "source_file": 3, + "team_id": 3, + "team_name": 3, + "team_url": 3, + "token_masked": 3, + "user_id": 3, + "version_timestamp": 3 + }, + "rows": 3 + } + }, + "corpus": "dleapp_slack_known", + "note": "Content-free fingerprint. Digests and counts only, no rows, because the corpora are private application profiles.", + "recorded_with_commit": "5c99ed324e9bd1022e21627036817111e2e49198" +} diff --git a/scripts/artifacts/SlackSession.py b/scripts/artifacts/SlackSession.py new file mode 100644 index 0000000..6c7560f --- /dev/null +++ b/scripts/artifacts/SlackSession.py @@ -0,0 +1,149 @@ +__artifacts_v2__ = { + "slackSessions": { + "name": "Slack Client Sessions", + "description": "Client usage sessions reconstructed from the Slack " + "desktop app's 'activitySession_' Local Storage " + "keys, one set per workspace. Each recovered version of " + "the key records a session with a start time, a " + "last-activity time and a last-logged time, giving a " + "usage timeline per workspace independent of any message " + "content. Because Local Storage is a LevelDB, superseded " + "versions of the key surface earlier sessions as well as " + "the most recent.", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-01", + "last_update_date": "2026-09-01", + "requirements": "none", + "category": "Slack (Desktop)", + "notes": "'Duration' is Last Activity minus Session Start and is best " + "read as a lower bound on how long the client was open for " + "that session, not a guarantee it was in the foreground the " + "whole time. Slack Desktop is closed source, so the field " + "meanings follow the observed activitySession structure; they " + "were validated against a constructed known-data fixture, not " + "confirmed against Slack's own documentation.", + "paths": ( + "*/Slack/Local Storage/leveldb/*", + "*/slack/Local Storage/leveldb/*", + ), + "output_types": ["standard"], + "artifact_icon": "clock", + "sample_data": { + "dleapp_slack_known": "Constructed known-data Slack Local Storage " + "fixture (authored, not from a real device) | 3 sessions " + "across 2 workspaces", + }, + }, +} + +import json +import struct +import zlib +from datetime import datetime, timezone + +from scripts.chromium.local_storage import leveldb_folders, read_records +from scripts.ilapfuncs import artifact_processor, logfunc + +_READ_ERRORS = (OSError, ValueError, EOFError, IndexError, KeyError, + struct.error, zlib.error) + + +def _records(context): + records = [] + for folder in leveldb_folders([str(f) for f in context.get_files_found()]): + try: + for record in read_records(folder): + if record.origin and "slack.com" not in record.origin.lower(): + continue + records.append(record) + except _READ_ERRORS as ex: + logfunc(f"Slack Client Sessions: could not read '{folder}': {ex}") + records.sort(key=lambda record: -record.sequence) + return records + + +def _load_json(record): + try: + return json.loads(record.value) + except (ValueError, TypeError): + return None + + +def _epoch_ms_to_dt(value): + if not value: + return "" + try: + return datetime.fromtimestamp(float(value) / 1000, tz=timezone.utc) + except (ValueError, TypeError, OSError): + return "" + + +def _format_duration(start_dt, end_dt): + if not isinstance(start_dt, datetime) or not isinstance(end_dt, datetime): + return "" + seconds = (end_dt - start_dt).total_seconds() + if seconds < 0: + return "" + minutes, secs = divmod(int(seconds), 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}h {minutes}m {secs}s" + if minutes: + return f"{minutes}m {secs}s" + return f"{secs}s" + + +@artifact_processor +def slackSessions(context): + data_headers = ( + "Team ID", "Session ID", ("Session Start", "datetime"), + ("Last Activity", "datetime"), ("Last Logged", "datetime"), "Duration", + "LevelDB Sequence", "Record State", "Source File", + ) + + data_list = [] + seen = set() + source_path = "" + + for record in _records(context): + if not record.key.startswith("activitySession_"): + continue + sessions = _load_json(record) + if not isinstance(sessions, dict): + continue + team_id = record.key[len("activitySession_"):] + + for session_id, session in sessions.items(): + if not isinstance(session, dict): + continue + source_path = source_path or context.get_relative_path(record.source) + + start_dt = _epoch_ms_to_dt(session.get("startTime")) + activity_dt = _epoch_ms_to_dt(session.get("lastActivity")) + logged_dt = _epoch_ms_to_dt(session.get("lastLogged")) + + key = (team_id, session_id, session.get("startTime"), + session.get("lastActivity"), session.get("lastLogged")) + if key in seen: + continue + seen.add(key) + + data_list.append(( + team_id, + session_id, + start_dt, + activity_dt, + logged_dt, + _format_duration(start_dt, activity_dt), + record.sequence, + record.state, + context.get_relative_path(record.source), + )) + + data_list.sort( + key=lambda row: row[2] if isinstance(row[2], datetime) + else datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + logfunc(f"Slack Client Sessions: {len(data_list)} session(s) recovered.") + return data_headers, data_list, source_path diff --git a/scripts/artifacts/slackWorkspaces.py b/scripts/artifacts/slackWorkspaces.py new file mode 100644 index 0000000..c6269b2 --- /dev/null +++ b/scripts/artifacts/slackWorkspaces.py @@ -0,0 +1,197 @@ +__artifacts_v2__ = { + "slackWorkspaces": { + "name": "Slack Workspaces", + "description": "Workspaces the Slack desktop app has been signed into, " + "parsed from its 'localConfig_v2' Local Storage key: " + "each workspace's name, URL, per-workspace user ID and " + "domain, which workspace was last active, the client's " + "own most-recently-used and version timestamps, the " + "channel or DM last open in it, and a per-workspace " + "session token. Because Local Storage is a LevelDB, " + "superseded versions of the key are recovered too, so " + "an earlier set of workspaces can appear alongside the " + "current one.", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-01", + "last_update_date": "2026-09-01", + "requirements": "none", + "category": "Slack (Desktop)", + "notes": "Session tokens (xoxc-/xoxb-/xoxp-/xoxs-/xoxr-/xoxd- prefixed) " + "are masked to the first 8 and last 4 characters, in the " + "Token column and inside Other Fields, since a full token is " + "a live credential. 'Most Recently Used' and 'Version " + "Timestamp' are two independent client-recorded times, not a " + "range, and are reported as separate columns. Purely cosmetic " + "fields (theme colours, sidebar gradients) are dropped from " + "Other Fields; anything else stored per workspace is kept " + "there. Slack Desktop is closed source, so the field meanings " + "follow the observed localConfig_v2 structure; they were " + "validated against a constructed known-data fixture, not " + "confirmed against Slack's own documentation.", + "paths": ( + "*/Slack/Local Storage/leveldb/*", + "*/slack/Local Storage/leveldb/*", + ), + "output_types": ["standard"], + "artifact_icon": "briefcase", + "sample_data": { + "dleapp_slack_known": "Constructed known-data Slack Local Storage " + "fixture (authored, not from a real device) | 3 workspace " + "records: a current config with 2 workspaces and one " + "superseded config with 1", + }, + }, +} + +import json +import re +import struct +import zlib +from datetime import datetime, timezone + +from scripts.chromium.local_storage import leveldb_folders, read_records +from scripts.ilapfuncs import artifact_processor, logfunc + +_TOKEN_RE = re.compile(r"xox[a-z]-[A-Za-z0-9-]+") + +# localConfig_v2 fields promoted to their own columns, plus purely cosmetic +# fields dropped from Other Fields. +_NOISE_FIELDS = { + "token", "name", "url", "user_id", "domain", + "mostRecentlyUsedDate", "versionDataTs", "lastRoute", "lastViewState", + "icon", "channelSidebarBackground", "teamSwitcherBackground", + "textColor", "customTheme", "windowGradient", "iaTheming", + "topNavBackground", "topNavTextColor", "theme", +} + +_READ_ERRORS = (OSError, ValueError, EOFError, IndexError, KeyError, + struct.error, zlib.error) + + +def _mask_tokens(text): + if not text: + return text + + def _mask(match): + token = match.group(0) + if len(token) <= 12: + return "[redacted token]" + return f"{token[:8]}...[redacted]...{token[-4:]}" + + return _TOKEN_RE.sub(_mask, text) + + +def _parse_iso8601(value): + if not value or not isinstance(value, str): + return "" + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return "" + + +def _epoch_seconds_to_dt(value): + if not value: + return "" + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except (ValueError, TypeError, OSError): + return "" + + +def _records(context): + records = [] + for folder in leveldb_folders([str(f) for f in context.get_files_found()]): + try: + for record in read_records(folder): + if record.origin and "slack.com" not in record.origin.lower(): + continue + records.append(record) + except _READ_ERRORS as ex: + logfunc(f"Slack Workspaces: could not read '{folder}': {ex}") + records.sort(key=lambda record: -record.sequence) + return records + + +def _load_json(record): + try: + return json.loads(record.value) + except (ValueError, TypeError): + return None + + +@artifact_processor +def slackWorkspaces(context): + data_headers = ( + "Team ID", "Team Name", "Team URL", "User ID", "Domain", + ("Most Recently Used", "datetime"), ("Version Timestamp", "datetime"), + "Last Viewed Channel/DM ID", "Token (masked)", "Last Active", + "Other Fields", "LevelDB Sequence", "Record State", "Source File", + ) + + data_list = [] + seen = set() + source_path = "" + + for record in _records(context): + if record.key != "localConfig_v2": + continue + config = _load_json(record) + if not isinstance(config, dict): + continue + teams = config.get("teams") + if not isinstance(teams, dict): + continue + source_path = source_path or context.get_relative_path(record.source) + last_active_id = config.get("lastActiveTeamId") + + for team_id, team in teams.items(): + if not isinstance(team, dict): + continue + + token_raw = team.get("token", "") + token_masked = _mask_tokens(token_raw) if token_raw else "" + + most_recently_used = _parse_iso8601(team.get("mostRecentlyUsedDate")) + version_ts = _epoch_seconds_to_dt(team.get("versionDataTs")) + + last_route = team.get("lastRoute") + last_entity_id = "" + if isinstance(last_route, dict): + last_entity_id = (last_route.get("params") or {}).get("entityId", "") + + other = { + k: (_mask_tokens(v) if isinstance(v, str) else v) + for k, v in team.items() + if k not in _NOISE_FIELDS + } + + key = (record.sequence, team_id, team.get("name"), team.get("url"), token_masked) + if key in seen: + continue + seen.add(key) + + data_list.append(( + team_id, + team.get("name", ""), + team.get("url", ""), + team.get("user_id", ""), + team.get("domain", ""), + most_recently_used, + version_ts, + last_entity_id, + token_masked, + "Yes" if team_id == last_active_id else "", + json.dumps(other, ensure_ascii=False) if other else "", + record.sequence, + record.state, + context.get_relative_path(record.source), + )) + + data_list.sort( + key=lambda row: row[5] if isinstance(row[5], datetime) + else datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + logfunc(f"Slack Workspaces: {len(data_list)} workspace record(s) recovered.") + return data_headers, data_list, source_path