dumbtv
dumbtv takes the ads, screen tracking and update nags out of your Fire TV over ADB. No root, none of your apps touched, every step can be undone. Free and open source.
Agent-native incident investigation graph: analysts and AI agents (MCP/REST) link evidence from EDR, Entra ID, cloud and GitHub logs into one shared, real-time attack graph and timeline. Self-hosted,
SecurityTypeScript · PythonGitHub @k3mpaxlOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Agent-native incident investigation graph: analysts and AI agents (MCP/REST) link evidence from EDR, Entra ID, cloud and GitHub logs into one shared, real-time attack graph and timeline. Self-hosted, no database.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
FactGraph is a lightweight relay service where analysts and AI agents work through larger security incidents together. The incident is modelled as a graph: entities (IPs, accounts, service principals, devices, key vaults, repositories …), the relationships and events between them, and for every claim the evidence from logs, KQL results or files. People work in the browser, agents via MCP or REST, all on the same board, synchronised live.Read the full README ↗FactGraph is a lightweight relay service where analysts and AI agents work through larger security incidents together. The incident is modelled as a graph: entities (IPs, accounts, service principals, devices, key vaults, repositories …), the relationships and events between them, and for every claim the evidence from logs, KQL results or files. People work in the browser, agents via MCP or REST, all on the same board, synchronised live.
Complex attacks do not show up in a single log. A foothold on an endpoint, a callback to an IP, a service principal sign-in, a secret read from a key vault and a clone of 39 repositories land in four different systems: EDR, Entra ID, Key Vault audit and GitHub. None of these sources references the others. The connection only becomes visible when you put the events side by side as a graph and in time order.
FactGraph is built for exactly that:
The result is a shared, traceable picture of the incident instead of findings scattered across chats, tickets and spreadsheets.
Analyst A (browser) Analyst B (browser)
IndexedDB ◄──────┐ ┌──────► IndexedDB
│ WebSocket│
┌─────┴──────────┴─────┐
│ FactGraph server │ stores no board data
│ relay · REST · MCP │
└─────┬──────────┬─────┘
│ REST │ MCP
scripts / imports AI agent (VS Code, Claude Code …)
409.From Docker Hub (amd64 and arm64):
docker run --rm -p 8080:8080 k3mpaxl/factgraph:latest
With Docker Compose from the repository:
docker compose -f deploy/compose.yaml up --build
Directly with Python 3.11+ and Node.js 20+ (the Docker image and CI use Python 3.14 and Node.js 26):
python3 -m venv .venv
.venv/bin/pip install --require-hashes -r requirements.lock
cd web && npm ci && npm run build && cd ..
.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
http://127.0.0.1:8080/mcp/ (Streamable HTTP)Every board URL contains a UUID (/boards/<uuid>). The board menu (top left) copies the link, creates new boards and lists the boards stored in this browser. Colleagues on the same network open http://<server-ip>:8080/boards/<uuid>. Compose binds to 127.0.0.1 by default; for the LAN:
FACTGRAPH_BIND_IP=0.0.0.0 docker compose -f deploy/compose.yaml up -d
Security: there is no user login. Anyone who can reach the server and knows a board link can read and change that board. Restrict network access (see Security by default and Azure App Service).
| Variable | Default | Meaning |
|---|---|---|
FACTGRAPH_MCP_TOOLS | agent | agent = compact MCP tool set, full = one rest_* tool per REST operation |
FACTGRAPH_ALLOWED_ORIGINS | (empty) | Extra browser origins allowed to join the relay, comma-separated (e.g. a dev server). Pages served by FactGraph itself are always allowed. |
FACTGRAPH_BIND_IP | 127.0.0.1 | Compose only: host interface the port is published on |
FactGraph is meant to be spun up during an incident, often in a hurry and sometimes while the regular infrastructure is compromised. The defaults are therefore as strict as possible without configuration:
Nothing at rest on the server
Access
X-FactGraph-Token header (never in URLs, so they do not end up in proxy or platform logs) and compared in constant time.Browser hardening (HTTP headers)
Content-Security-Policy: only the app's own scripts, styles, images and connections; no third-party content, no inline scripts (the one tiny theme script is allowed by hash), no framing (frame-ancestors 'none'), no plugins.Referrer-Policy: no-referrer: following a link in evidence never leaks the board URL to another site.X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy: same-origin, a restrictive Permissions-Policy.Strict-Transport-Security when served over HTTPS (also behind a TLS-terminating proxy), Cache-Control: no-store for API responses./docs loads its assets from a CDN and is therefore exempt from the CSP; it contains no board data.Container
/ws/boards/<board> instead of the ID.X-Forwarded-For only from loopback and link-local proxies (169.254.0.0/16, e.g. Azure App Service front ends), so a client cannot fake it. Behind another reverse proxy, set FORWARDED_ALLOW_IPS to its address.wss:// for the relay when the page is served over HTTPS.What it does not do
Azure App Service takes care of the certificate and TLS, scales down to a small plan and can be restricted to the responders' IP addresses. For a compromised environment, deploy into a separate, clean subscription or tenant, not into the affected one.
RG=rg-factgraph
APP=factgraph-ir-$RANDOM # becomes https://$APP.azurewebsites.net
az group create -n $RG -l westeurope
az appservice plan create -g $RG -n plan-factgraph --is-linux --sku B1
az webapp create -g $RG -p plan-factgraph -n $APP --container-image-name docker.io/k3mpaxl/factgraph:latest
az webapp config appsettings set -g $RG -n $APP --settings WEBSITES_PORT=8080
az webapp config set -g $RG -n $APP --web-sockets-enabled true --always-on true \
--min-tls-version 1.2 --ftps-state Disabled --http20-enabled true
az webapp update -g $RG -n $APP --https-only true
Restrict access to the response team (adding an allow rule denies everything else):
az webapp config access-restriction add -g $RG -n $APP --rule-name responders \
--action Allow --ip-address 198.51.100.0/24 --priority 100
Notes:
k3mpaxl/factgraph:0.6.2) over latest during an incident, so a restart never changes the version.az group delete -n $RG. Nothing remains on the server side.1–5), Find entity, undo/redo, Create and the API/MCP menu. On the left the collapsible entity explorer (grouped by type), on the right the inspector, which appears only when something is selected.N..env files in different repositories are told apart; same-named entities are flagged.Cmd/Ctrl+K opens the command palette; picking a result selects it and zooms to it with its neighbours.T) filter edges by review status and time span; the time window can be played back step by step.npm run bench checks this on a synthetic board.| Input | Action |
|---|---|
| Mouse wheel, trackpad pinch | Zoom |
Right mouse button drag, two-finger trackpad swipe, Space + drag | Pan |
Shift + mouse wheel | Pan sideways |
| Left mouse button drag on empty canvas | Box select |
Cmd/Ctrl+K | Command palette / find entity |
/ | Focus search |
N | New entity |
F | Fit graph or centre the selection |
G | Group selection |
1–4 | Switch view |
E / T | Explorer / time window |
Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z | Undo / redo |
Delete | Delete selection |
? | Help and shortcuts |
"The attacker uses IP a.a.a.a and service principal B and lists key vault C" is one event, not three edges. An activity has an operation (listed secrets), optionally a MITRE ATT&CK technique, a time span and participants with roles:
| Role | Meaning |
|---|---|
actor | who acts (attacker, user) |
identity | the identity used (account, service principal) |
source | where it came from (IP, device) |
tool | tool or process |
via | intermediate system |
target | what was acted upon |
other | other participants |
Evidence is attached to the whole event; review, status, timeline and time window work just like for relationships. On the canvas an activity is a diamond with labelled spokes; under Layers it can also be shown as a simple edge. The attribution "this IP belongs to the attacker" is a separate relationship with its own evidence.
Every type belongs to a layer: Identity & access, Network, Endpoint, Workload (Kubernetes, containers), Cloud control plane (subscriptions, key vaults), Data & storage (buckets, blobs, databases), Code & CI, Other. The layer is derived from the type and can be overridden per type or per entity.
Layers shows and hides layers (a badge on a node counts connections into hidden layers), draws swimlanes and arranges by layer. Combinations can be saved as a perspective, are synchronised with the board and can be linked with ?lens=<id>.
Collapse contents: if a device or cluster has contains, runs or hosts relationships, the context menu entry Collapse contents folds everything it contains into the node ("12 inside").
A group bundles many entities into one node, for example 699 of 700 repositories where the same thing happened. Members are explicit (multi-select, G) or rule-based (type and/or text pattern); new matching entities join automatically. Take out keeps individual entities visible outside the group, typically exactly the ones where something different happened. Edges to the group are bundled and counted (cloned ×38). Groups in the canvas toolbar suggests groups from entities of the same type with identical connections and names the outliers.
Group all similar (Groups popover or command palette) does this for the whole board in one step (one undo): first entities of one type with the same operations and the same counterparts in the same roles (41 Azure Resource · write cognitiveservices/accounts/deployments), then the rest of one type that share their counterparts with different operations, then those with the same operations and the same shared counterparts, each with partners of its own (89 files one user read from one IP, each in its own repository; a counterpart is shared when it takes part in at least four activities, and every group needs one). At least four per group; pinned and already grouped entities stay as they are, and so does the one that differs. The groups are collapsed and the smaller graph is arranged. Dropped exports do the same for the entities they add: a hub-and-spoke export such as AzureActivity (one service principal, one IP, hundreds of resources) arrives as a few groups instead of a fan of hundreds of nodes. Activities with the same operation between the same nodes are then drawn as one diamond with a count and the time span (delete ×12); expand the group to see them one by one.
When later evidence sets a member apart. A group made by Group all similar or an import remembers why its members belong together (the same links, the same counterparts, the same shared counterparts or the same network). If later evidence makes one of them different, for example the attacker also lists the keys of one of 41 resources, the group card says 1 differs and the group's inspector names it with what changed (+ listkeys) and takes it out in one click (one undo). A member is compared with what most members share now, so a change they all share (an import that adds the same activity to each) sets nobody apart. Groups are never split on their own: the badge is computed in every browser alike and nothing is written until an analyst takes members out. Groups you make by hand or by rule are not judged.
Collapsed and expanded groups share one place: drag the collapsed card and the members appear there when you expand it; drag an expanded group by its frame header and all members move along. Members that were scattered (collected by Group all similar or an import from all over the board) are laid out as a grid sorted by name right next to the card, growing away from the middle of the graph so the neighbouring cards stay visible; members you arranged yourself keep their arrangement. Activities that were drawn as one while the group was collapsed are placed between their participants again. Groups and perspectives change only the view, never claims or evidence.
Evidence your own file import parsed from a log row is confirmed right away as parsed by the import: the row is the original record, but nobody has read it. Evidence written by hand, sent by an agent through REST or MCP (including rows an agent imports, since it could have written them itself) or edited later starts unconfirmed and is checked in Review (attacker activity first, one card per activity). Review keeps the kinds apart: To review, Agent-confirmed, Parsed by import and Reviewed (by an analyst), so an empty queue is not mistaken for "everything checked". Each item says who added it and through which channel. In the reader, Previous / Next and All items step through every item of an activity (one card can stand for thousands of rows), Confirm this item only confirms exactly one, and the item's own row reads top to bottom with the key fields first and the column the event time came from. Changing the evidence or its source asks for a review again; the source editor says how many items that affects.
path@commit, portal link), the original records as an excerpt and, for queries, the KQL. primary sources are original logs, telemetry and files; secondary is context and never proof. FactGraph does not run queries; the query and the results it returned are stored together as provenance.supports/refutes) and a confidence.ProcessUniqueId with the device, and the same email across delivery, URLs, attachments, clicks and alerts (NetworkMessageId). The other keys of the xdrinternals.com catalog count too, by table and column: the same alert (AlertInfo, AlertEvidence, a Sentinel incident's AlertIds), behavior, threat intelligence object, Teams message, exposure graph node, CVE or configuration. A session, a process tree, a phishing mail or an incident can be followed without a query.valid_from/valid_to describe when the activity happened, not when it was recorded; unknown times are listed at the end.The Impact view (tab, command palette, or Mark compromised… in the inspector of any entity) answers the questions of an incident: what did the attacker do with what they stole, what did it reach, where to look next, and what has to be rotated.
In the graph, failed attempts are hidden by default and locations show as a flag on the IP (both switchable in Layers); credentials are laid out next to the identity they sign in as. IP groups are named after what their members share (network provider, country, tooling: 184 IP · AS8075 · NL · python-requests). The evidence time bar sits at the top of the workspace, shows how much evidence falls in each step, steps automatically or per day, hour, minute or event, and shows the attacker's window in red. Activities read as one sentence, every entity once (outlook.exe process created powershell.exe on ws-0142 as j.doe), in the inspector, the timeline and the review. A board opens on its busiest part when the whole graph would be unreadably small; Arrange picks left-to-right or top-to-bottom, whichever fits the window. The inspector shows an activity as a small diagram and evidence as fields (operation, who, on what, IP, result, location, credential, user agent, session) with the original row on demand. Compromised nodes are red, impacted ones orange with what happened to them, attacker activities and their edges red, and group cards say how many of their members are affected. The Impact lens in the canvas toolbar dims everything else and zooms to the attack; exported images carry the same markers. Copy report puts the whole picture into Markdown for a ticket. The analysis runs in the browser in one pass over the board (about 20 ms for 8,600 evidence items) and updates with every change; agents get it from get_impact.
When a board is opened, the browser creates a random board token: one per tab and board, valid while that tab has the board open (reloads keep it). REST and MCP expect it in the X-FactGraph-Token header (MCP also accepts the board_token parameter; the former name session_token still works). The token binds calls to this browser tab and its analyst; it is not a user account. A new tab or browser creates a new token. Changes an agent makes with your token are recorded as yours: the change log, notifications and evidence show MCP (Gregor) or REST (Gregor), or the agent's own name, e.g. Claude (Gregor).
Connect an agent (API/MCP menu or command palette) provides ready-to-copy snippets:
.vscode/mcp.json for VS Code / GitHub Copilot. By default VS Code asks for the token when the server starts, so the file can be committed; optionally with the token embedded.--scope project that stores the placeholder ${FACTGRAPH_TOKEN} in .mcp.json (safe to commit; each analyst exports the variable)./entities?q=…&limit=50) and what the error codes mean for other clients.find_entities instead of reading the whole board. No board ID is needed: the token names the board.The repository also contains .vscode/mcp.json; in VS Code run MCP: List Servers → factgraph → Start. From another device, replace 127.0.0.1 with the server's IP.
By default the server exposes a compact agent profile with 31 tools (about 10,600 tokens of tool descriptions). Fewer tools mean less context usage and better tool choice.
The board token in the X-FactGraph-Token header names the board (every browser tab makes its own token for each board), so agent tools take no board ID and agents never need to ask for one. A board_id passed anyway is accepted and must match the token. Clients that cannot send headers pass board_token in each call; the full profile also shows board_id as an optional parameter.
| Task | MCP tool | REST (relative to /api/boards/{id}) |
|---|---|---|
| Read the graph, find entities | get_graph, find_entities | GET /graph, GET /entities?q= |
| Entities | create_entity, update_entity, merge_entities, delete_entity, add_identifier | /entities… |
| Relationships | create_relation, update_relation, delete_relation | /relations… |
| Activities | create_activity, update_activity | /activities… |
| Sources and evidence | create_source, update_source, add_evidence, update_evidence, review_evidence, retract_evidence | /sources…, /relations/{id}/evidence… |
| Imports | import_defender_rows (built-in tables or a format), list_import_formats, import_rows, import_activities | /imports/table, /imports/formats, /imports/inspect, /imports/preview, /imports/file, /imports/activity, /imports/kql, /imports/activities |
| Attack impact | get_impact; mark with update_entity (compromise: {from, to, note}, rotated_at) | GET /impact, PATCH /entities/{id} |
| Overview and export | create_group, update_group, export_image | /groups…, /export |
| Next query: where a column leads | find_joins | GET /api/relations?table=&column= (no board, no token) |
| What is missing, and answering it | get_gaps, |
The REST API stays complete (types, perspectives, single-item reads, history, redo, raw actions …). With FACTGRAPH_MCP_TOOLS=full the MCP server instead exposes one rest_<operation> tool per REST operation, with identical parameters and validation:
docker run -e FACTGRAPH_MCP_TOOLS=full -p 8080:8080 k3mpaxl/factgraph:latest
On connect the server sends binding working rules to the agent (also available via GET /api/guidelines): read first and reuse existing IDs; concrete entities; specific verbs; an activity for three or more participants; primary evidence for every important claim; keep uncertainty and contradictions; run imports as dry_run first. Agents may confirm evidence, but only after checking the original record and with a review note.
get_gaps lists what the board still needs to make its entities and relationships clear, most important first, each as a question with filled-in KQL, the platform to run it on (Defender XDR advanced hunting or Sentinel) and the table mapping that imports the rows:
IdentityInfo by object ID or UPN): the name for a user known only by an ID, the on-premises SID (joins Windows logons and logged-on users to the same entity) and the Entra roles, active and PIM-eligible, as holds roles with a role entity, so who else holds Global Administrator is one hop away.DeviceInfo): Defender and Entra device IDs, the public IPs it reported from (the legitimate egress) and the logged-on users already on the board.OAuthAppInfo): the app behind a service principal or app ID, with privilege level and permissions in the row.DeviceProcessEvents by process start key and device): the launch, its command line and parent, for activities whose process was never launched on the board.AADServicePrincipalSignInLogs: IPs, key IDs, resources) or managed identity (AADManagedIdentitySignInLogs), Azure operations of a user or service principal (AzureActivity by Caller: listKeys, role assignments, deletes), Microsoft Graph calls (MicrosoftGraphActivityLogs: enumeration, mailbox reads, new app credentials) and the legacy Azure AD Graph (AADGraphActivityLogs, used by AADInternals and ROADrecon). An identity counts as covered for a table as soon as evidence from it touches the identity, so a dropped export closes the gap too.Gaps cover what the attack touches (once something is marked compromised), otherwise every connected entity. The agent runs the query, or asks the analyst to, and sends the rows with submit_gap_rows: rows of a known table are imported and matched to the board's entities like a dropped export, rows of no known table are kept in the answer's source. An empty result answers a gap too (with a note such as "table not in this tenant"). Every answer is a source with the URI factgraph:gap/<id>, so the gap is closed for every browser and agent; the reply names the next gap. When the board changed between get_gaps and the answer (new entities widen a gap, so its ID changes), the answer is refused with the current gap's ID, and rows that fit a table are imported anyway; a repeated submission adds nothing twice. get_neighbors reads one entity's surroundings up to three hops instead of the whole board.
TOKEN=... # board token, from "Connect an agent" or "Copy board token"
BOARD=... # board UUID
curl -X POST "http://127.0.0.1:8080/api/boards/$BOARD/entities" \
-H "X-FactGraph-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"203.0.113.7","kind":"IP"}'
curl -X POST "http://127.0.0.1:8080/api/boards/$BOARD/activities" \
-H "X-FactGraph-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"operation":"listed secrets","participants":[
{"entity_id":"ACTOR_ID","role":"actor"},{"entity_id":"IP_ID","role":"source"},
{"entity_id":"SP_ID","role":"identity"},{"entity_id":"KV_ID","role":"target"}],
"valid_from":"2026-09-28T10:42:07Z","source_id":"SOURCE_ID",
"observation":"SecretList from 203.0.113.7 as sp-deploy-prod","locator":"CorrelationId=7f3a"}'
Python with FastMCP (the token in the header names the board):
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport("http://127.0.0.1:8080/mcp/", headers={"X-FactGraph-Token": "TOKEN"})
async with Client(transport) as client:
result = await client.call_tool("create_entity", {"body": {"name": "Azure credential", "kind": "Credential"}})
print(result.data)
PATCH-style calls change only the fields they contain; an explicit null clears a field. Sources and evidence accept expected_revision; on a concurrent change the API answers 409. For bulk work there is POST /actions (up to 50,000 actions; your own action IDs make retries idempotent) and the import endpoints. There is deliberately no endpoint that lists all boards.
Import logs and exports (board menu) or dropping files on the board accepts CSV, JSON arrays or JSONL up to 20 MB and 50,000 rows per file. Every file is read first; nothing is stored until you import.
Built-in formats: Defender XDR and Sentinel tables (see below) and the classic two-column access log (IPAddress → FilePath) are recognised by their columns. Dropped on the board, they are imported right away.
Your own formats: any other export opens the column mapping. Every column is listed with its value type and a few sample values; say what it is:
CorrelationId).Each row is either an event (an activity with several participants) or a relationship (A → B, e.g. an inventory). A live preview shows the first rows as they will arrive, and which rows would be skipped. Save as a format: the next export with (mostly) the same columns, also in another order or with a column more, is recognised and imported without asking, also when dropped. Formats are kept in this browser for all boards; under Saved formats they can be exported as a file for colleagues, and theirs imported.
The board helps with the mapping: FactGraph compares the values with the names and IDs of the entities already on the board. A column of GUIDs that are the Entra object IDs of users a Sentinel import brought in is suggested as those users (on board: User · Entra object ID); the preview shows them by name, and the import adds to them instead of creating new entities. Column names and what the values look like (IPs, e-mail addresses, URLs, host and file names, times) fill in the rest of the suggestion.
Check, then import: Check import runs every file without storing anything (rows, new and known entities, activities, evidence, the time column); Import runs them in the background, one after another, each with its own entry under the bell. Uploads appear as channel Import in the change log.
Every row becomes its own evidence item with its timestamp and locator; the same operation with the same participants becomes one shared activity. Importing the same rows again creates no duplicates, also after an update that reads more from them: a row already on the board (recognised by a key of its original content) adds no evidence, and its entities on the board only get the IDs the newer version reads.
Agents and scripts: POST /imports/inspect (multipart) returns the columns, sample values, matches on the board and a suggested format; POST /imports/preview shows what a format makes of a few rows; GET /imports/formats lists the formats saved in the analyst's browser (MCP list_import_formats); POST /imports/table with the rows as JSON (MCP import_defender_rows) and POST /imports/file take a format. The earlier endpoints /imports/activity, /imports/kql and /imports/activities (with roles) remain. All support dry_run.
{
"title": "Key Vault AuditEvent 2026-09-28",
"query": "AzureDiagnostics | where OperationName startswith \"Secret\"",
"rows": [{"CallerIPAddress": "203.0.113.7", "identity_claim_oid_g": "11111111-2222-3333-4444-555555555555", "Resource": "KV-PROD-SECRETS",
"OperationName": "SecretList", "ResultSignature": "OK", "CorrelationId": "c-1", "TimeGenerated": "2026-09-28T10:42:07Z"}],
"format": {
"name": "Key Vault diagnostics",
"entities": [{"column": "CallerIPAddress", "kind": "IP", "role": "source"},
{"column": "identity_claim_oid_g", "kind": "User", "role": "actor", "ids": [{"column": "identity_claim_oid_g", "type": "entra-object-id"}]},
{"column": "Resource", "kind": "Key Vault", "role": "target"}],
"operation_column": "OperationName", "time": "TimeGenerated", "locator": ["CorrelationId"],
"details": [{"column": "ResultSignature", "label": "result"}]
},
"dry_run": true
}
Export the results of an advanced hunting query (Defender XDR) or a Log Analytics query (Microsoft Sentinel) as CSV or JSON and drop the files onto the board. Each file is recognised by its columns and imported in the background, one after another, with an entry under the bell; an empty board is arranged afterwards. One file is one undo step.
DeviceProcessEvents, DeviceNetworkEvents, DeviceFileEvents, DeviceLogonEvents, DeviceRegistryEvents, DeviceImageLoadEvents, DeviceEvents, IdentityLogonEvents, IdentityDirectoryEvents, IdentityQueryEvents, EntraIdSignInEvents, EntraIdSpnSignInEvents, GraphAPIAuditEvents, CloudAppEvents, UrlClickEvents, EmailEvents, EmailUrlInfo, EmailAttachmentInfo, AlertEvidence; Sentinel SigninLogs, AADNonInteractiveUserSignInLogs, AADServicePrincipalSignInLogs, AADManagedIdentitySignInLogs, MicrosoftGraphActivityLogs and AADGraphActivityLogs (calls grouped by request with object IDs replaced, GET /v1.0/users/{id}/messages; failed ones apart), AuditLogs, AzureActivity, AzureDiagnostics (Key Vault), SecurityEvent, CommonSecurityLog, OfficeActivity, the normalised ASIM fields (SrcIpAddr, TargetUsername …), and GitLab audit events (e.g. a custom table such as GitLabAuditLogs_CL: author, IP, repository, file, access token, SSH key, git push/pull/clone). Other documented advanced hunting tables (65 in total) are mapped from their column names.DeviceNetworkEvents the device (source), the account (actor), the initiating process (via), the remote IP and URL (target). The operation comes from ActionType, OperationName or the sign-in result (“signed in”, “sign-in failed (50126)”); the time from Timestamp/TimeGenerated; the locator names the table and the record, for example DeviceNetworkEvents ReportId=18842 DeviceId=…. Columns that are not mapped stay in the evidence row.ProcessUniqueId, InitiatingProcessUniqueId, with the DeviceId) joins a process seen with its path in DeviceProcessEvents and without it in . It is matched only after the stronger IDs, so a board imported before keeps its entities and a repeated import adds nothing.The table schemas (names, columns, types; no description text) are generated from the Microsoft Learn docs via defender-docs-mirror: python scripts/build_table_schemas.py <path to a clone> writes app/data/table_schemas.json.
Which column of one table refers to the same token, session, user, device, app or resource as a column of another table comes from the relationship catalog of xdrinternals.com (MIT License, Copyright (c) 2025 Fabian & Nathan, see app/data/column_relations.LICENSE). python scripts/build_column_relations.py writes app/data/column_relations.json (and the key columns for the browser, web/src/joinColumns.ts); the weekly workflow Relationship catalog runs it with --check and fails, listing the added, removed and changed joins, when the catalog on xdrinternals.com has changed. For each join it keeps both ends (table, column, JSON path, time column), the kind of key, the guidance (for example: token IDs are case-sensitive, remove only trailing padding) and the KQL.
find_joins (REST GET /api/relations?table=AADGraphActivityLogs&column=SignInActivityId) where a column of an evidence row leads, and get the other tables with the KQL; without arguments it lists the kinds of keys.Export image (image icon in the canvas toolbar, board menu or command palette) exports the graph as it is currently shown, with filters, layers, groups and activities:
export_image or POST /export; the response contains content as SVG text or base64 PNG:curl -s -X POST "http://127.0.0.1:8080/api/boards/$BOARD/export" \
-H "X-FactGraph-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"format":"png","theme":"light","scale":2}' | jq -r .content | base64 -d > graph.png
A Syncing … changes indicator in the header shows larger syncs (reconnects, imports, colleagues' batches) and a short Synced when done. Messages above 1 MB go to the relay gzip-compressed in a binary frame, so the 16 MiB WebSocket limit applies to the compressed size (up to 192 MiB unpacked); large imports are additionally kept in source parts.
UVICORN_WS_PING_TIMEOUT).Cmd/Ctrl+Z) reverts your own last group of actions (an import counts as one group); changes agents made through this browser are not yours and are not undone by it (an agent's undo in turn only takes back REST/MCP batches). Undo refuses if someone else has built on the same records since, for example a relationship to an entity you would remove. The Change log lists every change with channel (UI, REST, MCP, Import), author and time, searchable and filterable by channel, author and kind; each change shows its fields with the earlier value () and links to the entity..venv/bin/python -m unittest discover -s tests -v # backend, REST/MCP contracts, relay
cd web
npm ci
npm test # projection, review, convergence, export, layout, wheel, notifications, saved view
npm run bench # performance on a synthetic 12,000-action board; fails on large regressions
npx playwright install chromium
npm run test:e2e # browser workflows against a real server
npm run format # Prettier (TypeScript, 140 columns); CI runs format:check
cd .. && pip install --require-hashes -r requirements-dev.lock && ruff format app tests scripts # Python, the same width
The web interface is split by purpose: App.tsx (state, shortcuts, layout), Inspector.tsx, Dialogs.tsx, Panels.tsx (timeline, change log, explorer, command palette) and ui.tsx (shared building blocks).
The browser tests run on temporary boards on port 18088 with a real MCP client. Among other things they cover analyst and agent review, offline sync with a concurrent merge in two browsers, activities, groups (including moving collapsed and expanded groups), layers, presence, export, mouse and trackpad navigation, the mobile layout, imports with preview and deduplication, and 10,000 imported evidence items.
The screenshots in this README are generated from a scripted incident:
cd web && npm run build && DOCS_SCREENSHOTS=1 npx playwright test e2e/docs-screenshots.spec.ts
linux/amd64 and linux/arm64 on a version tag (v*.*.*) or via Run workflow, and publishes it as latest and with the version number. It requires the repository secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a Docker Hub access token with write permission).git tag v0.6.2
git push origin v0.6.2
A manual multi-arch build is possible with ./deploy/publish-multiarch.sh (FACTGRAPH_IMAGE and FACTGRAPH_VERSION override namespace and version).
UniqueTokenIdentifierUniqueTokenIdutiSignInActivityIdSignInActivityIdSessionId in sign-in and Graph logs, AADSessionId in Microsoft 365 and Cloud App events). A session the attacker used from their infrastructure that was in use before from another IP was hijacked (adversary-in-the-middle phishing, a stolen session cookie or refresh token): revoke the sessions, reset the credentials, check new MFA methods. If the others came only after the attacker, it is the owner still working in it or another exit of the attacker. A session roams with its user, so nothing is marked from it alone. Everything in the attacker's sign-in sessions lists every token, IP and identity per session..env / key files read), deleted, changed, read, signed in to, or only attempted (failed or denied).glab script lists their keys.KeyDescription, one row per key: CredentialChange, KeyId, KeyType, Application, Actor …). Export it as CSV and drop it on the board: removed keys join the credentials from the sign-ins by key ID, keys added during the attack show up as backdoors. Where else was the stolen credential used, and is it still accepted? is importable too; after the rotation only failed attempts may remain.submit_gap_rowsGET /gaps, POST /gaps/{gap_id}/rows |
| One entity's surroundings | get_neighbors | GET /entities/{id}/neighbors?hops= |
| Undo | undo | /undo |
DeviceNetworkEventsj.doe from DeviceProcessEvents and from SigninLogs is one user. A different ID under the same name (another DeviceId, another hash) creates a separate entity instead of a wrong merge.DeviceInfo, IdentityInfo (Defender XDR and Sentinel UEBA) and OAuthAppInfo describe what is already on the board and never fill it: a row is used only when its device, user or app is there. It adds that entity's IDs (Entra device ID, Entra object ID, on-premises SID, app and service principal IDs) and replaces a bare GUID by the display name; an IdentityInfo row becomes holds roles with the user's active and PIM-eligible Entra roles as role entities; a DeviceInfo row with a public IP becomes reported from public IP (where the device was online, the egress a baseline needs) with the logged-on users already on the board; and an OAuthAppInfo row links an app's service principal to the app users sign in to when both are on the board. Rows about anything else are left out and counted.AzureActivity (Claims.appid, object ID from the Caller), the RBAC role and error code, LocationDetails.countryOrRegion (country as a location entity; city, ASN and named network in the observation), and the credential a service principal signed in with (ServicePrincipalCredentialKeyId, thumbprint) as its own entity. A service principal first seen only by its object ID gets its display name from the next sign-in export. Evidence notes store the row with JSON columns as objects. [UTC] suffix and the US date format of Log Analytics exports, 7-digit fractions, JSON columns such as DeviceDetail or InitiatedBy, a byte-order mark.IPAddress → FilePath) is imported as relationships as before. Anything else opens the import dialog with a suggested mapping (see above); saved as a format, the next export of that kind imports like a built-in table.POST /imports/table with the rows (MCP import_defender_rows), or POST /imports/file with auto=true; both support dry_run.