From 6a482b31cde3b5617a1e28fe1e34f1d5cf208574 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:19:36 +0000 Subject: [PATCH 1/4] IdentityInfo and DeviceInfo say how entities relate: roles, logged-on users A mapping part can now read a list column (Part.each): one participant per element, at most 50. IdentityInfo (Defender XDR and Sentinel UEBA) becomes "holds roles": the user with their active Entra roles (target) and PIM-eligible ones (other) as role entities, so who else holds a role is one hop away. DeviceInfo adds the logged-on users already on the board to "reported from public IP" (by on-premises SID, which IdentityInfo adds to users, or DOMAIN\name); unknown users are not created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK --- app/tables.py | 35 +++++++++++++++++++++++++++--- tests/test_relations.py | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/app/tables.py b/app/tables.py index 92d102e..037de5c 100644 --- a/app/tables.py +++ b/app/tables.py @@ -195,6 +195,9 @@ class Part: # Processes: the column with the Windows process start key of this launch. With the DeviceId it says which process # entity a row means when the path is missing (DeviceNetworkEvents often has no InitiatingProcessFolderPath). start: tuple[str, ...] = () + # A list in the row (a JSON column such as AssignedRoles): one participant per element (at most 50). Fields, IDs + # and the name function then read the element (a role name, or {"UserName", "DomainName", "Sid"}). + each: str = "" SCOPED_KINDS = ("File", "Process") @@ -712,8 +715,19 @@ def location(fields=("LocationDetails.countryOrRegion", "Location", "Country")) ( Part("actor", "Device", ("DeviceName",), DEVICE_IDS + (Ident("DeviceName", "fqdn"),), lower=True, create=False), Part("source", "IP", ("PublicIP",), (Ident("PublicIP", "ip"),)), + # Who was logged on, when those users are on the board (by their on-premises SID or DOMAIN\\name). + Part( + "identity", + "User", + ("UserName",), + (Ident("Sid", namespace="windows-sid"),), + name=account(domain="DomainName", name="UserName"), + lower=True, + create=False, + each="LoggedOnUsers", + ), ), - operation="reported from public IP", + operation=lambda r, g: "reported from public IP" if text(g("PublicIP")) else "reported logged-on users", locator=("ReportId", "DeviceId"), inventory=True, details=(("OS", "OSPlatform"), ("join type", "JoinType"), ("device group", "MachineGroup"), ("exposure", "ExposureLevel")), @@ -735,8 +749,12 @@ def location(fields=("LocationDetails.countryOrRegion", "Location", "Country")) lower=True, create=False, ), + # Entra roles held (active) and eligible through PIM: User → holds roles → Role, so who else holds a role is + # one hop away and a compromised privileged account stands out. + Part("target", "Role", ("value",), each="AssignedRoles"), + Part("other", "Role", ("value",), each="PrivilegedEntraPimRoles"), ), - operation="identity inventory", + operation="holds roles", locator=("ReportId", "AccountObjectId"), inventory=True, ), @@ -758,8 +776,9 @@ def location(fields=("LocationDetails.countryOrRegion", "Location", "Country")) lower=True, create=False, ), + Part("target", "Role", ("value",), each="AssignedRoles"), ), - operation="identity inventory", + operation="holds roles", time=("TimeGenerated",), locator=("AccountObjectId",), inventory=True, @@ -1398,6 +1417,16 @@ def participants_of(mapping: Mapping, row: Row) -> list[dict]: g = lambda path: get(row, path, cache) # noqa: E731 out = [] for part in mapping.parts: + if part.each: + items = g(part.each) + items = parse_json(items) if isinstance(items, str) else items + for item in (items if isinstance(items, list) else [])[:50]: + element = item if isinstance(item, dict) else {"value": item} + single = Part( + part.role, part.kind, part.fields or ("value",), part.ids, part.lower, part.name, part.kind_of, create=part.create + ) + out.extend(participants_of(Mapping(mapping.table, mapping.product, (), (single,)), element)) + continue raw = next((text(g(f)) for f in part.fields if text(g(f))), "") name = text(part.name(row, g)) if part.name else raw if not name: diff --git a/tests/test_relations.py b/tests/test_relations.py index de9d6f5..fdd6d00 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -183,6 +183,54 @@ def test_rows_enrich_entities_on_the_board_and_add_nothing_else(self): {(d["payload"]["namespace"], d["payload"]["raw_value"]) for d in drafts if d["type"] == "identifier.add"}, ) + def test_roles_and_logged_on_users_become_relationships(self): + board, index = str(uuid4()), {"entities": [], "facts": [], "sources": []} + run(board, [dict(r) for r in NETWORK], index) # user j.doe (object ID 11111111-…), device a1b2c3d4e5f6 + identities = [ + { + "AccountObjectId": "11111111-2222-3333-4444-555555555555", + "AccountUpn": "J.Doe@corp.example", + "OnPremSid": "S-1-5-21-1-2-3-1104", + "AssignedRoles": '["Global Administrator", "Security Reader"]', + "PrivilegedEntraPimRoles": '["Privileged Role Administrator"]', + "ReportId": "1", + "Timestamp": "2026-09-28T08:00:00Z", + } + ] + _, drafts, summary = run(board, identities, index) + user = next(e["id"] for e in index["entities"] if e["kind"] == "User") + holds = next(d["payload"] for d in drafts if d["type"] == "fact.add") + roles = {e["id"]: e["name"] for e in index["entities"] if e["kind"] == "Role"} + self.assertEqual(holds["predicate"], "holds roles") + self.assertEqual( + sorted((roles.get(p["entity_id"], p["entity_id"]), p["role"]) for p in holds["participants"]), + sorted( + [ + ("Global Administrator", "target"), + ("Privileged Role Administrator", "other"), + ("Security Reader", "target"), + (user, "identity"), + ] + ), + ) + self.assertEqual(summary["evidence"], 1) + # The on-premises SID IdentityInfo added joins DeviceInfo's logged-on user to the same entity. + devices = [ + { + "Timestamp": "2026-09-28T09:00:00Z", + "DeviceId": "a1b2c3d4e5f6", + "DeviceName": "ws-0142.corp.example", + "OSPlatform": "Windows11", + "PublicIP": "198.51.100.20", + "LoggedOnUsers": '[{"UserName": "jdoe", "DomainName": "CORP", "Sid": "S-1-5-21-1-2-3-1104"}, {"UserName": "ghost", "Sid": "S-1-5-21-9"}]', + "ReportId": "7", + } + ] + _, drafts, _ = run(board, devices, index) + report = next(d["payload"] for d in drafts if d["type"] == "fact.add") + self.assertIn({"entity_id": user, "role": "identity"}, report["participants"]) + self.assertNotIn("ghost", {d["payload"]["name"] for d in drafts if d["type"] == "entity.add"}, "unknown users are not added") + def test_an_app_known_only_by_its_id_gets_its_name_and_both_sides_are_linked(self): board, index = str(uuid4()), {"entities": [], "facts": [], "sources": []} # The app as users sign in to it (Service "Azure Portal", app ID c44b4083-…) and its service principal, known only From b76fa02082164313ecaa670024690fffc35d4c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:19:36 +0000 Subject: [PATCH 2/4] Gaps for agents: what is missing, with KQL, answered through one interface get_gaps (GET /api/boards/{id}/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 and the mapping that imports the rows: who a user is and which roles they hold (IdentityInfo), what a device is and who uses it (DeviceInfo), which app a service principal is (OAuthAppInfo), where a process came from (DeviceProcessEvents by start key and device), and the attack impact's hunts for tables nothing was imported from. Scope: what the attack touches once something is marked, otherwise every connected entity. Computed in the browser (web/src/gaps.ts), like every board read. submit_gap_rows (POST /gaps/{gap_id}/rows) imports the result rows like a dropped export, keeps rows of no known table in the answer's source, and records the answer, also an empty one, as a source with the URI factgraph:gap/: the gap is closed for every browser and agent, and the reply names the next gap. A gap that changed since get_gaps is refused with its current ID; rows that fit a table are imported anyway. get_neighbors (GET /entities/{id}/neighbors) reads one entity's surroundings up to three hops instead of the whole board. The agent profile grows to 31 tools (about 10,600 tokens of descriptions); the server instructions tell agents to work through get_gaps until empty. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK --- README.md | 18 ++- app/descriptions.py | 3 + app/extensions.py | 118 +++++++++++++- app/main.py | 3 +- app/tool_profiles.py | 12 ++ tests/test_mcp_contracts.py | 9 +- web/e2e/workflows.spec.ts | 75 +++++++++ web/package.json | 2 +- web/src/gaps.ts | 309 ++++++++++++++++++++++++++++++++++++ web/src/useBoard.ts | 11 ++ web/tests/gaps.cjs | 185 +++++++++++++++++++++ 11 files changed, 738 insertions(+), 7 deletions(-) create mode 100644 web/src/gaps.ts create mode 100644 web/tests/gaps.cjs diff --git a/README.md b/README.md index b2700e8..4a68052 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ The repository also contains [.vscode/mcp.json](.vscode/mcp.json); in VS Code ru ### MCP tools -By default the server exposes a **compact agent profile with 28 tools** (about 9,850 tokens of tool descriptions). Fewer tools mean less context usage and better tool choice. +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. @@ -322,6 +322,8 @@ The board token in the `X-FactGraph-Token` header names the board (every browser | 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`, `submit_gap_rows` | `GET /gaps`, `POST /gaps/{gap_id}/rows` | +| One entity's surroundings | `get_neighbors` | `GET /entities/{id}/neighbors?hops=` | | Undo | `undo` | `/undo` | 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_` tool per REST operation, with identical parameters and validation: @@ -332,6 +334,18 @@ 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. +### What is missing: gaps an agent answers + +`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: + +- **Who is this user, which roles do they hold** (`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. +- **What is this device, who uses it** (`DeviceInfo`): Defender and Entra device IDs, the public IPs it reported from (the legitimate egress) and the logged-on users already on the board. +- **Which app is this** (`OAuthAppInfo`): the app behind a service principal or app ID, with privilege level and permissions in the row. +- **Where did this process come from** (`DeviceProcessEvents` by process start key and device): the launch, its command line and parent, for activities whose process was never launched on the board. +- **Open hunts** of the attack impact for tables nothing was imported from yet (session tokens, sign-in sessions, the attacker's IPs …). + +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/`, 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. + ### REST examples ```bash @@ -409,7 +423,7 @@ Export the results of an advanced hunting query (Defender XDR) or a Log Analytic - **Mapping:** every row becomes evidence for an activity whose participants carry roles, for example in `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. - **One process launch, one entity:** the Windows process start key (`ProcessUniqueId`, `InitiatingProcessUniqueId`, with the `DeviceId`) joins a process seen with its path in `DeviceProcessEvents` and without it in `DeviceNetworkEvents`. It is matched only after the stronger IDs, so a board imported before keeps its entities and a repeated import adds nothing. - **The same thing stays one entity:** devices, accounts, files, apps and Azure resources are stored with their identifiers (DeviceId, AadDeviceId, Entra object ID, SID, SHA-256/SHA-1/MD5, app ID, resource ID, FQDN, UPN, IP) and found by them on later imports, so `j.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. -- **Inventory tables** `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; a `DeviceInfo` row with a public IP becomes *reported from public IP* (where the device was online, the egress a baseline needs), 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. +- **Inventory tables** `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. - **Nested JSON columns** are read too: the service principal's app ID from the token claims in `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. - **Export quirks** are handled: the ` [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. - **Not recognised?** A plain two-column log (`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. diff --git a/app/descriptions.py b/app/descriptions.py index 6559cea..cb4ad3c 100644 --- a/app/descriptions.py +++ b/app/descriptions.py @@ -16,6 +16,9 @@ 'restore_evidence': 'Restore retracted evidence. It returns as unconfirmed and needs a new analyst review.', 'redo_board': 'Redo the last undone action group of this session (same batch as the undo).', 'guidelines': 'Return the evidence-first modelling rules for agents (same text as the server instructions).', + 'list_gaps': 'What the board still needs to make its entities clear (user roles, device and app details, process launches, open hunts): each gap with filled-in KQL and the table that imports its rows.', + 'submit_gap_rows': "Answer a gap with the result rows of its query (an empty list when nothing came back). Rows of a known table are imported; the answer is kept as a source and closes the gap.", + 'get_neighbors': "An entity's neighbourhood up to three hops: entities, relationships and activities, shortened. Cheaper than reading the whole graph.", 'list_column_relations': 'Which columns join a Defender XDR or Sentinel table to other tables (token, session, user, device, app, resource IDs), from the xdrinternals.com catalog: per join the other table and column, the guidance and KQL. Needs no board and no token.', 'delete_source': 'Delete a source. Refused while evidence or identifiers still reference it; detach them first.', 'delete_relation': 'Delete a relationship or an activity together with all its evidence. Entities stay.', diff --git a/app/extensions.py b/app/extensions.py index 6184e88..4ba8366 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -4,7 +4,8 @@ import inspect from typing import Annotated, Literal, get_type_hints -from uuid import uuid4 +import json +from uuid import UUID, uuid4, uuid5 from fastapi import HTTPException from fastapi.routing import APIRoute from fastmcp.tools import FunctionTool @@ -13,9 +14,20 @@ from app.descriptions import describe_routes, purpose, tool_description from app.tool_profiles import AGENT_TOOLS, annotations_for, configured_profile, set_tool_profile from app.ingest import action +from app.tables import clean_rows from app.relations import find_joins +class GapRowsInput(StrictModel): + rows: list[dict] = Field( + default_factory=list, + max_length=50_000, + description="The result rows of the gap's query as returned. An empty list says it found nothing, which answers the gap too.", + ) + note: str = Field(default='', max_length=2000, description='What was run, or why nothing came back (e.g. "table not in this tenant").') + query: str = Field(default='', max_length=20_000, description="The KQL actually run, if it differs from the gap's query.") + + class ReviewInput(StrictModel): review_status: Literal['confirmed', 'unconfirmed'] expected_revision: str @@ -225,6 +237,110 @@ def list_column_relations(table: str = '', column: str = '', family: str = '', l raise HTTPException(422, 'limit must be 1 to 100') return find_joins(table=table.strip(), column=column.strip(), family=family.strip(), limit=limit) + @app.get('/api/boards/{board_id}/gaps') + async def list_gaps(board_id: str, limit: int = 10): + if not 1 <= limit <= 50: + raise HTTPException(422, 'limit must be 1 to 50') + report = await command(board_id, 'gaps') + gaps = report.get('gaps', []) + return { + 'board_id': board_id, + 'total': len(gaps), + 'answered': len(report.get('answered', [])), + 'gaps': gaps[:limit], + 'next': 'Run a gap\'s query (platform says where) and send its result rows with submit_gap_rows, also when it returns none.' + if gaps + else 'Nothing missing: every gap is answered.', + } + + @app.post('/api/boards/{board_id}/gaps/{gap_id}/rows') + async def submit_gap_rows(board_id: str, gap_id: str, body: GapRowsInput): + report = await command(board_id, 'gaps') + gaps = report.get('gaps', []) + gap = next((g for g in gaps if g['id'] == gap_id), None) + try: + rows = clean_rows(body.rows) if body.rows else [] + except ValueError as error: + raise HTTPException(422, str(error)) from error + if gap is None: + if any(a['id'] == gap_id for a in report.get('answered', [])): + raise HTTPException(409, f'Gap {gap_id} is already answered; get_gaps lists what is still open') + # The board moved on since get_gaps (new entities widen a gap, so its ID changes). Rows that fit a table are + # still imported; a repeated submission adds nothing twice. + imported = None + if rows: + try: + imported = await core['import_table'](board_id, rows, query=body.query) + except HTTPException: + imported = None + now = [g for g in gaps if g['rule'] == gap_id.split(':')[0]] + raise HTTPException( + 409, + f'Gap {gap_id} changed since get_gaps' + + (f'; {imported.get("evidence", 0)} evidence items were imported from the rows' if imported else '') + + (f'. It is now {now[0]["id"]}: run its query and submit to it.' if now else '. Call get_gaps again.'), + ) + query = body.query or gap['query'] + imported, stored = None, False + if rows: + try: + imported = await core['import_table'](board_id, rows, query=query) + except HTTPException as error: + if error.status_code != 422: + raise + stored = True # no table mapping reads these rows: they are kept in the answer's source + meta = {'gap': gap_id, 'rule': gap['rule'], 'entities': gap.get('entities', []), 'rows': len(rows), 'note': body.note} + excerpt = json.dumps(meta, ensure_ascii=False) + if stored: + excerpt += '\n' + json.dumps(rows, ensure_ascii=False, default=str)[:2_000_000] + source_id = str(uuid5(UUID(board_id), f'gap-answer\x1f{gap_id}')) + title = f'Answer: {gap["question"]} · ' + (f'{len(rows)} rows' if rows else 'nothing found') + await apply( + board_id, + [ + action( + 'source.add', + { + 'id': source_id, + 'title': title[:200], + 'uri': f'factgraph:gap/{gap_id}', + 'excerpt': excerpt, + 'query': query, + 'source_kind': 'primary' if stored else 'secondary', + }, + action_id=str(uuid5(UUID(board_id), f'gap-answer-action\x1f{gap_id}')), + author='Agent', + ) + ], + ) + remaining = [g for g in gaps if g['id'] != gap_id] + result = { + 'board_id': board_id, + 'gap_id': gap_id, + 'rows': len(rows), + 'answer_source_id': source_id, + 'remaining': len(remaining), + **({'next_gap': {k: remaining[0][k] for k in ('id', 'priority', 'question')}} if remaining else {}), + } + if imported: + result['imported'] = { + k: imported.get(k) + for k in ('table', 'evidence', 'relations', 'new_entities', 'identifiers', 'enriched', 'already_on_board') + if imported.get(k) is not None + } + if stored: + result['stored'] = ( + 'No table mapping reads these rows: they are kept in the answer source. Model what they show with create_activity or add_evidence citing answer_source_id.' + ) + return result + + @app.get('/api/boards/{board_id}/entities/{entity_id}/neighbors') + async def get_neighbors(board_id: str, entity_id: str, hops: int = 1, limit: int = 200): + if not 1 <= hops <= 3 or not 1 <= limit <= 1000: + raise HTTPException(422, 'hops must be 1 to 3, limit 1 to 1000') + result = await command(board_id, 'neighbors', entity_id=entity_id, hops=hops, limit=limit) + return {k: v for k, v in result.items() if k not in ('ok', 'type', 'requestId')} + def collection_routes(resource, key): # The browser filters and paginates, so only the requested slice crosses the WebSocket. Lists shorten long # texts (_truncated, _length); a single record is complete. diff --git a/app/main.py b/app/main.py index 675d28a..cc6ea39 100644 --- a/app/main.py +++ b/app/main.py @@ -77,7 +77,8 @@ 7. Large graphs: create_group bundles many similar entities into one collapsed node (members, or rule by kinds/name match, or a container's contents); excluded keeps anomalies visible on their own. Groups only change the view. 8. Layers (identity, network, endpoint, workload = Kubernetes/containers, cloud = control plane/Key Vaults, data = buckets/blobs/databases, code = repositories/CI, other) are inferred from the kind; set layer only to correct it. 9. export_image renders the graph as SVG (text) or PNG (base64) for reports. -10. Next query: find_joins(table, column) says where a column of an evidence row leads (same token, session, user, device, app in other tables) with the KQL; follow its guidance (token IDs are case-sensitive, a missing match is no proof). +10. What is missing: get_gaps lists questions the board cannot answer yet (who a user is and which roles they hold, what a device is and who uses it, where a process came from, open hunts), each with KQL. Run it (or ask the analyst to), then submit_gap_rows with the rows, also when there are none. Repeat until get_gaps is empty. get_neighbors reads one entity's surroundings instead of the whole board. +11. Next query: find_joins(table, column) says where a column of an evidence row leads (same token, session, user, device, app in other tables) with the KQL; follow its guidance (token IDs are case-sensitive, a missing match is no proof). Editing: update tools change only the fields you pass; explicit null clears nullable fields. Pass expected_revision for sources and evidence; HTTP 409 means someone changed it — re-read and retry; 404 means the record does not exist. undo reverts the last change batch made through REST/MCP (never the analyst's own edits) and refuses when someone built on it since. Content changes reset affected reviews. Times: ISO 8601; a time without a zone is UTC. Compromise marks you set are "suspected" unless you pass level "confirmed" and the evidence proves it. diff --git a/app/tool_profiles.py b/app/tool_profiles.py index e096ea5..86dd694 100644 --- a/app/tool_profiles.py +++ b/app/tool_profiles.py @@ -52,6 +52,18 @@ 'find_joins', 'Where else a column of a Defender XDR / Sentinel row leads (token, session, user, device, app IDs): other table and column, join guidance, KQL. Plan the next query; no args lists key kinds.', ), + 'list_gaps': ( + 'get_gaps', + 'What the board still needs, most important first: each gap a question with filled-in KQL, the platform to run it on and the table that imports the rows. Answer with submit_gap_rows.', + ), + 'submit_gap_rows': ( + 'submit_gap_rows', + "Send the result rows of a gap's query (also none: that answers it). Rows of a known table are imported and matched to the board's entities; the gap closes; returns the next gap.", + ), + 'get_neighbors': ( + 'get_neighbors', + "An entity's neighbourhood up to 3 hops: entities with IDs and compromise marks, relationships and activities with evidence counts and times. Cheaper than get_graph.", + ), 'create_group': ('create_group', None), 'update_group': ('update_group', None), 'export_image': ('export_image', None), diff --git a/tests/test_mcp_contracts.py b/tests/test_mcp_contracts.py index aa460c4..9ec379a 100644 --- a/tests/test_mcp_contracts.py +++ b/tests/test_mcp_contracts.py @@ -84,6 +84,9 @@ class AgentGuidance(unittest.IsolatedAsyncioTestCase): 'undo', 'list_import_formats', 'find_joins', + 'get_gaps', + 'submit_gap_rows', + 'get_neighbors', } async def test_default_agent_profile_is_small_and_explained(self): @@ -95,8 +98,10 @@ async def test_default_agent_profile_is_small_and_explained(self): # 9,300: import_defender_rows (about 250 tokens) was added in v0.5.0; 9,500: get_impact and the compromise # marking on update_entity (about 250 tokens) for attack impact; 9,700: list_import_formats and the format of # import_defender_rows (about 500 tokens) for saved import formats; 9,900: find_joins (about 180 tokens) for the - # next query from an evidence row. Keep further growth deliberate. - self.assertLess(size // 4, 9900, f'agent tool definitions use about {size // 4} tokens') + # next query from an evidence row; 10,700: get_gaps, submit_gap_rows and get_neighbors (about 720 tokens) so an + # agent learns what is missing, answers it and reads one entity's surroundings instead of the whole board. Keep + # further growth deliberate. + self.assertLess(size // 4, 10700, f'agent tool definitions use about {size // 4} tokens') for tool in tools: self.assertGreater(len(tool.description or ''), 30, tool.name) self.assertNotIn('REST equivalent', tool.description, tool.name) diff --git a/web/e2e/workflows.spec.ts b/web/e2e/workflows.spec.ts index b2bb9e4..f087fd9 100644 --- a/web/e2e/workflows.spec.ts +++ b/web/e2e/workflows.spec.ts @@ -1581,3 +1581,78 @@ test('linked by ID: an activity leads to the process launch it ran in and to wha await expect(page.locator('.linked-list').getByText('Then the process it started')).toBeVisible() await expect(page.locator('.linked-list .linked-row', { hasText: 'connection success' })).toBeVisible() }) + +test('agents: get_gaps names what is missing with KQL, submit_gap_rows imports the answer and closes the gap', async ({ + page, + request, +}) => { + const { id, token, call, graph } = await setup(page, request) + const oid = '11111111-2222-3333-4444-555555555555' + const graphApp = '00000003-0000-0000-c000-000000000000' + const user = (await call('POST', '/entities', { name: oid, kind: 'User' })).id + await call('POST', `/entities/${user}/identifiers`, { scheme: 'external_id', namespace: 'entra-object-id', raw_value: oid }) + const ip = (await call('POST', '/entities', { name: '198.51.100.7', kind: 'IP' })).id + const app = (await call('POST', '/entities', { name: graphApp, kind: 'Service' })).id + await call('POST', `/entities/${app}/identifiers`, { scheme: 'external_id', namespace: 'entra-app-id', raw_value: graphApp }) + await call('POST', '/activities', { + operation: 'signed in', + participants: [ + { entity_id: user, role: 'identity' }, + { entity_id: ip, role: 'source' }, + { entity_id: app, role: 'target' }, + ], + valid_from: '2026-09-28T10:00:00Z', + }) + // The agent asks what is missing: the user known only by its object ID, with the KQL to run. + const listed = await mcp(token, 'get_gaps', {}) + const identity = listed.gaps.find((g: any) => g.rule === 'identity-info') + expect([identity.priority, identity.imports_as, identity.entities]).toEqual(['high', 'IdentityInfo', [user]]) + expect(identity.query).toContain(oid) + // It runs the query and sends the rows: imported, the gap closes, the next one is named. + const answer = await mcp(token, 'submit_gap_rows', { + gap_id: identity.id, + body: { + rows: [ + { + AccountObjectId: oid, + AccountUpn: 'J.Doe@corp.example', + OnPremSid: 'S-1-5-21-1-2-3-1104', + AccountDisplayName: 'Jane Doe', + AssignedRoles: '["Global Administrator"]', + ReportId: '1', + Timestamp: '2026-09-28T08:00:00Z', + }, + ], + }, + }) + expect([answer.imported.table, answer.imported.evidence]).toEqual(['IdentityInfo', 1]) + expect(answer.next_gap.id).toMatch(/^app-info:/) + // Nothing found is an answer too. + const empty = await mcp(token, 'submit_gap_rows', { + gap_id: answer.next_gap.id, + body: { rows: [], note: 'first-party app, not in OAuthAppInfo' }, + }) + expect([empty.rows, empty.remaining]).toEqual([0, 0]) + const after = await call('GET', '/gaps') + expect([after.total, after.answered]).toEqual([0, 2]) + const board = await graph() + expect(board.entities.find((e: any) => e.id === user).name).toBe('j.doe@corp.example') + const holds = board.facts.find((f: any) => f.predicate === 'holds roles') + expect(holds.participants.map((p: any) => board.entities.find((e: any) => e.id === p.entity_id).name).sort()).toEqual([ + 'Global Administrator', + 'j.doe@corp.example', + ]) + expect(board.sources.filter((s: any) => s.uri.startsWith('factgraph:gap/')).map((s: any) => s.title)).toContainEqual( + expect.stringMatching(/nothing found$/), + ) + // The user's neighbourhood instead of the whole board. + const around = await mcp(token, 'get_neighbors', { entity_id: user, hops: 1 }) + expect(around.relations.map((r: any) => r.predicate).sort()).toEqual(['holds roles', 'signed in']) + // A gap that changed meanwhile is refused with the current one named. + const stale = await request.fetch(`${base}/api/boards/${id}/gaps/identity-info:000000000000/rows`, { + method: 'POST', + data: { rows: [] }, + headers: { 'X-FactGraph-Token': token }, + }) + expect(stale.status()).toBe(409) +}) diff --git a/web/package.json b/web/package.json index 383de58..5e707d7 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "tsc src/board.ts src/timeline.ts src/validation.ts src/viewModel.ts src/exportGraph.ts src/wheel.ts src/organicLayout.ts src/layout.ts src/savedView.ts src/notifications.ts src/explain.ts src/query.ts src/impact.ts src/hunting.ts src/describe.ts src/rows.ts src/provenance.ts src/time.ts src/importBatches.ts src/sync.ts src/importFormats.ts src/correlation.ts src/rowKey.ts --outDir node_modules/.cache/factgraph-tests --module commonjs --target es2022 --skipLibCheck --esModuleInterop && node tests/timeline.cjs ./node_modules/.cache/factgraph-tests && node tests/review.cjs ./node_modules/.cache/factgraph-tests && node tests/structures.cjs ./node_modules/.cache/factgraph-tests && node tests/export.cjs ./node_modules/.cache/factgraph-tests && node tests/wheel.cjs ./node_modules/.cache/factgraph-tests && node tests/layout.cjs ./node_modules/.cache/factgraph-tests && node tests/view.cjs ./node_modules/.cache/factgraph-tests && node tests/notifications.cjs ./node_modules/.cache/factgraph-tests && node tests/explain.cjs ./node_modules/.cache/factgraph-tests && node tests/query.cjs ./node_modules/.cache/factgraph-tests && node tests/impact.cjs ./node_modules/.cache/factgraph-tests && node tests/importformats.cjs ./node_modules/.cache/factgraph-tests && node tests/correlation.cjs ./node_modules/.cache/factgraph-tests", + "test": "tsc src/board.ts src/timeline.ts src/validation.ts src/viewModel.ts src/exportGraph.ts src/wheel.ts src/organicLayout.ts src/layout.ts src/savedView.ts src/notifications.ts src/explain.ts src/query.ts src/impact.ts src/hunting.ts src/describe.ts src/rows.ts src/provenance.ts src/time.ts src/importBatches.ts src/sync.ts src/importFormats.ts src/correlation.ts src/rowKey.ts src/gaps.ts --outDir node_modules/.cache/factgraph-tests --module commonjs --target es2022 --skipLibCheck --esModuleInterop && node tests/timeline.cjs ./node_modules/.cache/factgraph-tests && node tests/review.cjs ./node_modules/.cache/factgraph-tests && node tests/structures.cjs ./node_modules/.cache/factgraph-tests && node tests/export.cjs ./node_modules/.cache/factgraph-tests && node tests/wheel.cjs ./node_modules/.cache/factgraph-tests && node tests/layout.cjs ./node_modules/.cache/factgraph-tests && node tests/view.cjs ./node_modules/.cache/factgraph-tests && node tests/notifications.cjs ./node_modules/.cache/factgraph-tests && node tests/explain.cjs ./node_modules/.cache/factgraph-tests && node tests/query.cjs ./node_modules/.cache/factgraph-tests && node tests/impact.cjs ./node_modules/.cache/factgraph-tests && node tests/importformats.cjs ./node_modules/.cache/factgraph-tests && node tests/correlation.cjs ./node_modules/.cache/factgraph-tests && node tests/gaps.cjs ./node_modules/.cache/factgraph-tests", "test:e2e": "npm run build && playwright test", "bench": "tsc src/board.ts src/timeline.ts src/validation.ts src/viewModel.ts src/layout.ts src/impact.ts --outDir node_modules/.cache/factgraph-bench --module commonjs --target es2022 --skipLibCheck --esModuleInterop && node tests/bench.cjs ./node_modules/.cache/factgraph-bench" }, diff --git a/web/src/gaps.ts b/web/src/gaps.ts new file mode 100644 index 0000000..ae8b96a --- /dev/null +++ b/web/src/gaps.ts @@ -0,0 +1,309 @@ +import type { Entity, Fact, GraphData } from './types' +import { analyzeImpact, compromiseOf, type Impact } from './impact' +import { huntingQueries } from './hunting' +import { correlationIndex } from './correlation' +import { rowKey } from './rowKey' + +/** + * What the board still lacks to make its entities and their relationships clear, as questions an agent (or analyst) + * answers with one query each: who a user is and which roles they hold, what a device is and who uses it, which app a + * service principal is, where a process came from, and the hunts the attack impact suggests that nothing answered yet. + * Every gap carries filled-in KQL and says which built-in table mapping reads its result rows; submit_gap_rows imports + * them and closes the gap. A gap answered (also with no rows: "nothing there" is an answer) is remembered on the board + * as a source with the URI factgraph:gap/, so every browser and agent sees it closed. + */ +export type Gap = { + id: string + rule: GapRule + priority: 'high' | 'medium' | 'low' + question: string + why: string + /** Entities the answer is about (the IDs submit_gap_rows marks as answered). */ + entities: string[] + tables: string[] + /** Where the query runs: Defender XDR advanced hunting, or Microsoft Sentinel / Log Analytics. */ + platform: 'defender' | 'sentinel' + query: string + /** The built-in mapping that imports the result rows; without one the rows are kept as a source. */ + imports_as?: string +} +export type GapRule = 'identity-info' | 'device-info' | 'app-info' | 'process-launch' | 'hunt' +export type AnsweredGap = { id: string; rule: string; entities: string[]; rows: number; note: string; source_id: string; at: string } +export type GapReport = { gaps: Gap[]; answered: AnsweredGap[] } + +export const GAP_URI = 'factgraph:gap/' +const MAX_VALUES = 200 +const literal = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +const list = (values: string[]) => `dynamic([${values.map(literal).join(', ')}])` +const ids = (entity: Entity, namespace: string) => entity.identifiers.filter(i => i.namespace === namespace).map(i => i.normalized_value) +const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const hash = (parts: string[]) => rowKey([...parts].sort().join('\n')).slice(0, 12) + +/** Gaps answered on the board: sources whose URI names the gap, with what the answer covered. */ +export function answeredGaps(data: Pick): AnsweredGap[] { + const out: AnsweredGap[] = [] + for (const source of data.sources) { + if (!source.uri?.startsWith(GAP_URI)) continue + let meta: Record = {} + try { + meta = JSON.parse(source.excerpt.slice(0, source.excerpt.indexOf('\n') > 0 ? source.excerpt.indexOf('\n') : undefined)) + } catch { + /* an answer written by hand */ + } + const id = source.uri.slice(GAP_URI.length) + out.push({ + id, + rule: typeof meta.rule === 'string' ? meta.rule : id.split(':')[0], + entities: Array.isArray(meta.entities) ? meta.entities.filter((e): e is string => typeof e === 'string') : [], + rows: typeof meta.rows === 'number' ? meta.rows : 0, + note: typeof meta.note === 'string' ? meta.note : '', + source_id: source.id, + at: source.created_at, + }) + } + return out +} + +/** + * The entities the attack touches: the attacker's activities, everything that met a marked entity (a device that + * connected to the attacker's IP is not the attacker's activity, but it matters), pivots and impacted resources. Before + * anything is marked: every connected entity. + */ +function relevant(data: Pick, impact: Impact): Set { + const out = new Set() + const ends = (fact: Fact) => (fact.participants?.length ? fact.participants.map(p => p.entity_id) : [fact.subject_id, fact.object_id]) + if (impact.seeds.length) { + for (const attack of impact.facts.values()) for (const id of ends(attack.fact)) out.add(id) + for (const fact of data.facts) if (ends(fact).some(id => impact.marks.has(id))) for (const id of ends(fact)) out.add(id) + for (const pivot of impact.pivots) out.add(pivot.entity.id) + for (const hit of impact.impacted) out.add(hit.entity.id) + } else for (const fact of data.facts) for (const id of ends(fact)) out.add(id) + return out +} + +/** UTC times of the evidence of these facts, for a query window that also finds what happened just before. */ +function windowOf(facts: Fact[]): string { + const times = facts.flatMap(f => f.assertions.map(a => a.valid_from).filter((t): t is string => !!t && !Number.isNaN(Date.parse(t)))) + if (!times.length) return 'let start = ago(30d);\nlet end = now();\n' + times.sort() + const day = 86_400_000 + return `let start = datetime(${new Date(Date.parse(times[0]) - 7 * day).toISOString()});\nlet end = datetime(${new Date(Date.parse(times.at(-1)!) + day).toISOString()});\n` +} + +export function boardGaps(data: GraphData, impact: Impact = analyzeImpact(data)): GapReport { + const answered = answeredGaps(data) + const covered = new Map>() + for (const answer of answered) { + const set = covered.get(answer.rule) ?? new Set() + for (const id of answer.entities) set.add(id) + set.add(answer.id) + covered.set(answer.rule, set) + } + const isCovered = (rule: GapRule, id: string) => covered.get(rule)?.has(id) ?? false + const scope = relevant(data, impact) + const byId = new Map(data.entities.map(e => [e.id, e])) + const marked = (id: string) => impact.marks.get(id) === 'compromised' || impact.marks.get(id) === 'derived' + const candidates = (pattern: RegExp, rule: GapRule, usable: (e: Entity) => boolean) => + [...scope] + .map(id => byId.get(id)) + .filter((e): e is Entity => !!e && pattern.test(e.kind) && usable(e) && !isCovered(rule, e.id)) + .sort((a, b) => Number(marked(b.id)) - Number(marked(a.id)) || Number(GUID.test(b.name)) - Number(GUID.test(a.name))) + .slice(0, MAX_VALUES) + const gaps: Gap[] = [] + const add = (gap: Omit, covers: string[]) => gaps.push({ id: `${gap.rule}:${hash(covers)}`, ...gap }) + const urgent = (entities: Entity[]) => entities.some(e => marked(e.id) || GUID.test(e.name)) + + // Users: names, on-premises SID (joins Windows logons and DeviceInfo's logged-on users) and Entra roles. + const users = candidates( + /\buser\b|account|person/i, + 'identity-info', + e => ids(e, 'entra-object-id').length > 0 || e.identifiers.some(i => i.scheme === 'email'), + ) + const userIds = users.flatMap(e => ids(e, 'entra-object-id')) + const upns = users.flatMap(e => e.identifiers.filter(i => i.scheme === 'email').map(i => i.normalized_value)) + if (userIds.length || upns.length) + add( + { + rule: 'identity-info', + priority: urgent(users) ? 'high' : 'medium', + question: `Who ${users.length === 1 ? 'is this user' : `are these ${users.length} users`}, and which Entra roles do they hold?`, + why: 'Names users known only by an ID, adds the on-premises SID (so Windows logons and logged-on users join the same entity) and the active and PIM-eligible Entra roles as "holds roles": who is privileged, and who else holds the same role.', + entities: users.map(e => e.id), + tables: ['IdentityInfo'], + platform: 'defender', + imports_as: 'IdentityInfo', + query: `IdentityInfo\n| where ${[userIds.length && `AccountObjectId in~ (${list(userIds)})`, upns.length && `AccountUpn in~ (${list(upns)})`].filter(Boolean).join(' or ')}\n| summarize arg_max(Timestamp, *) by AccountObjectId`, + }, + users.map(e => e.id), + ) + + // Devices: Defender and Entra device IDs, public IPs it reported from (the egress), who is logged on. + const reported = new Set( + data.facts + .filter(f => /^reported (from public IP|logged-on users)$/.test(f.predicate)) + .flatMap(f => (f.participants ?? []).map(p => p.entity_id)), + ) + const devices = candidates(/device|host|workstation|server|endpoint|machine/i, 'device-info', e => !reported.has(e.id)) + const deviceIds = devices.flatMap(e => ids(e, 'mde-device-id')) + const aadIds = devices.flatMap(e => ids(e, 'entra-device-id')) + const names = devices.filter(e => !ids(e, 'mde-device-id').length).map(e => e.name) + if (devices.length) + add( + { + rule: 'device-info', + priority: urgent(devices) ? 'high' : 'medium', + question: `What ${devices.length === 1 ? 'is this device' : `are these ${devices.length} devices`}: its IDs, the public IPs it was online from, and who was logged on?`, + why: 'Adds the Defender and Entra device IDs (so sign-ins and endpoint events join the same device), the public IPs it reported from (the legitimate egress: an attacker IP is then told apart from the office) and the logged-on users already on the board.', + entities: devices.map(e => e.id), + tables: ['DeviceInfo'], + platform: 'defender', + imports_as: 'DeviceInfo', + query: `DeviceInfo\n| where Timestamp > ago(30d)\n| where ${[deviceIds.length && `DeviceId in~ (${list(deviceIds)})`, aadIds.length && `AadDeviceId in~ (${list(aadIds)})`, names.length && `DeviceName in~ (${list(names)})`].filter(Boolean).join(' or ')}\n| summarize arg_max(Timestamp, *) by DeviceId, PublicIP`, + }, + devices.map(e => e.id), + ) + + // Apps and service principals: which app it is, its privilege level and permissions. + const apps = candidates( + /service principal|application|managed identity|^service$|\bapp\b/i, + 'app-info', + e => ids(e, 'entra-app-id').length > 0 || ids(e, 'entra-object-id').length > 0, + ) + const appIds = apps.flatMap(e => ids(e, 'entra-app-id')) + const spIds = apps.filter(e => /service principal|managed identity/i.test(e.kind)).flatMap(e => ids(e, 'entra-object-id')) + if (appIds.length || spIds.length) + add( + { + rule: 'app-info', + priority: urgent(apps) ? 'high' : 'low', + question: `Which ${apps.length === 1 ? 'app is this' : `apps are these ${apps.length}`}, and how privileged?`, + why: 'Names apps known only by an ID and links a service principal to the app users sign in to; the privilege level, permissions and consent are in the evidence row.', + entities: apps.map(e => e.id), + tables: ['OAuthAppInfo'], + platform: 'defender', + imports_as: 'OAuthAppInfo', + query: `OAuthAppInfo\n| where ${[appIds.length && `OAuthAppId in~ (${list(appIds)})`, spIds.length && `ServicePrincipalId in~ (${list(spIds)})`].filter(Boolean).join(' or ')}\n| summarize arg_max(Timestamp, *) by OAuthAppId`, + }, + apps.map(e => e.id), + ) + + // Processes whose launch is not on the board: the parent, the command line and who started it. + const index = correlationIndex(data) + const launched = new Set() + for (const [key, uses] of index) if (key.startsWith('process:') && uses.some(u => u.role === 'started')) launched.add(key.slice(8)) + // Facts that matter: the attacker's activities and whatever met a marked entity (everything, before anything is marked). + const attackFacts = new Set(impact.facts.keys()) + for (const fact of data.facts) + if ((fact.participants?.map(p => p.entity_id) ?? [fact.subject_id, fact.object_id]).some(id => impact.marks.has(id))) + attackFacts.add(fact.id) + const missing = new Map; attack: boolean }>() + for (const [key, uses] of index) { + if (!key.startsWith('process:') || launched.has(key.slice(8))) continue + for (const use of uses) { + if (use.role !== 'by') continue + const inScope = attackFacts.size ? attackFacts.has(use.fact) : true + if (!inScope) continue + const entry = missing.get(key.slice(8)) ?? { facts: new Set(), attack: false } + entry.facts.add(use.fact) + entry.attack ||= attackFacts.has(use.fact) + missing.set(key.slice(8), entry) + } + } + const processKeys = [...missing.keys()].filter(k => !isCovered('process-launch', k)).slice(0, MAX_VALUES) + if (processKeys.length) { + const facts = data.facts.filter(f => processKeys.some(k => missing.get(k)!.facts.has(f.id))) + add( + { + rule: 'process-launch', + priority: processKeys.some(k => missing.get(k)!.attack) ? 'high' : 'medium', + question: `Where did ${processKeys.length === 1 ? 'this process' : `these ${processKeys.length} processes`} come from: parent, command line, account?`, + why: 'Activities on the board ran in processes whose launch is not on the board (DeviceProcessEvents by the process start key and device). The launch shows the command line and the parent, the next step back in the attack chain.', + entities: processKeys, + tables: ['DeviceProcessEvents'], + platform: 'defender', + imports_as: 'DeviceProcessEvents', + query: `${windowOf(facts)}DeviceProcessEvents\n| where Timestamp between (start .. end)\n| where strcat(tolower(DeviceId), "|", tostring(ProcessUniqueId)) in (${list(processKeys)})`, + }, + processKeys, + ) + } + + // The hunts the attack impact suggests for tables nothing was imported from yet. + const urgentHunts = new Set(['session-replay', 'signin-session', 'ip-everywhere', 'credential-usage']) + for (const hunt of huntingQueries(data, impact)) { + if (hunt.imported) continue + const id = `hunt:${hunt.id}:${hash([hunt.query])}` + if (isCovered('hunt', id)) continue + gaps.push({ + id, + rule: 'hunt', + priority: urgentHunts.has(hunt.id) ? 'high' : 'medium', + question: hunt.title, + why: hunt.why, + entities: [], + tables: hunt.tables, + platform: 'sentinel', + query: hunt.query, + ...(hunt.importable ? { imports_as: hunt.importable } : {}), + }) + } + const order = { high: 0, medium: 1, low: 2 } + gaps.sort((a, b) => order[a.priority] - order[b.priority]) + return { gaps, answered } +} + +/** + * The neighbourhood of an entity up to `hops` steps (1–3): the entities and the relationships and activities between + * them, each shortened, so an agent reads what it needs instead of the whole board. + */ +export function neighborhood(data: GraphData, entityId: string, hops = 1, limit = 200) { + const byId = new Map(data.entities.map(e => [e.id, e])) + if (!byId.has(entityId)) return null + const ends = (fact: Fact) => (fact.participants?.length ? fact.participants.map(p => p.entity_id) : [fact.subject_id, fact.object_id]) + const touching = new Map() + for (const fact of data.facts) for (const id of ends(fact)) touching.set(id, [...(touching.get(id) ?? []), fact]) + const seen = new Set([entityId]) + const facts = new Map() + let frontier = [entityId] + for (let step = 0; step < Math.min(Math.max(hops, 1), 3) && frontier.length && facts.size < limit; step++) { + const next: string[] = [] + for (const id of frontier) + for (const fact of touching.get(id) ?? []) { + if (facts.size >= limit) break + if (!facts.has(fact.id)) facts.set(fact.id, fact) + for (const other of ends(fact)) if (!seen.has(other)) (seen.add(other), next.push(other)) + } + frontier = next + } + const times = (fact: Fact) => + fact.assertions + .map(a => a.valid_from) + .filter((t): t is string => !!t) + .sort() + return { + entity_id: entityId, + hops: Math.min(Math.max(hops, 1), 3), + truncated: facts.size >= limit, + entities: [...seen] + .map(id => byId.get(id)!) + .filter(Boolean) + .map(e => ({ + id: e.id, + name: e.name, + kind: e.kind, + identifiers: e.identifiers.map(i => `${i.namespace || i.scheme}:${i.raw_value}`).slice(0, 8), + ...(compromiseOf(e) ? { compromise: compromiseOf(e) } : {}), + })), + relations: [...facts.values()].map(f => { + const t = times(f) + return { + id: f.id, + predicate: f.predicate, + truth_state: f.truth_state, + ...(f.participants?.length ? { participants: f.participants } : { subject_id: f.subject_id, object_id: f.object_id }), + evidence: f.assertions.filter(a => !a.retracted_at).length, + ...(t.length ? { first: t[0], last: t.at(-1) } : {}), + } + }), + } +} diff --git a/web/src/useBoard.ts b/web/src/useBoard.ts index 229bc1f..4839284 100644 --- a/web/src/useBoard.ts +++ b/web/src/useBoard.ts @@ -1,4 +1,5 @@ import { rowKey } from './rowKey' +import { boardGaps, neighborhood } from './gaps' import { impactReport } from './hunting' import { analyzeImpact, exportMarks } from './impact' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -617,6 +618,16 @@ export function useBoard(boardId: string, listener?: ChangeListener) { }) } else if (message.operation === 'impact') { reply(requestId, { ok: true, impact: impactReport(projectCached(actionsRef.current).data) }) + } else if (message.operation === 'gaps') { + reply(requestId, { ok: true, ...boardGaps(projectCached(actionsRef.current).data) }) + } else if (message.operation === 'neighbors') { + const result = neighborhood( + projectCached(actionsRef.current).data, + String(message.entity_id ?? ''), + Number(message.hops ?? 1), + Math.min(Math.max(Number(message.limit ?? 200), 1), 1000), + ) + reply(requestId, result ? { ok: true, ...result } : { ok: false, error: 'Entity does not exist on this board' }) } else if (message.operation === 'query') { const result = queryRecords(projectCached(actionsRef.current).data, { collection: String(message.collection ?? ''), diff --git a/web/tests/gaps.cjs b/web/tests/gaps.cjs new file mode 100644 index 0000000..578e513 --- /dev/null +++ b/web/tests/gaps.cjs @@ -0,0 +1,185 @@ +const assert = require('node:assert/strict') +const path = require('node:path').resolve(process.argv[2]) +const { project } = require(path + '/board.js') +const { boardGaps, neighborhood, GAP_URI } = require(path + '/gaps.js') + +// A small incident: a user known only by its object ID signs in from the attacker's IP to an app, a device whose +// process connected out (its launch is not on the board), and a service principal with an app ID. +let clock = 0 +const ops = [] +const act = (type, payload) => + ops.push({ id: `a${++clock}`, clock, at: '2026-10-04T00:00:00.000Z', actor: 'me', author: 'Analyst', boardId: 'b', type, payload }) +const entity = (id, name, kind, identifiers = []) => { + act('entity.add', { id, name, kind }) + identifiers.forEach(([namespace, value, scheme = 'external_id'], i) => + act('identifier.add', { id: `${id}-i${i}`, entity_id: id, scheme, namespace, raw_value: value, normalized_value: value }), + ) +} +const activity = (id, predicate, participants, note, at) => { + act('fact.add', { id, predicate, participants: participants.map(([entity_id, role]) => ({ entity_id, role })) }) + act('assertion.add', { id: `${id}-e`, fact_id: id, stance: 'supports', valid_from: at, note: JSON.stringify(note), source_id: null }) +} +const oid = '11111111-2222-3333-4444-555555555555' +entity('user', oid, 'User', [['entra-object-id', oid]]) +entity('evil', '198.51.100.7', 'IP') +entity('app', 'Azure Portal', 'Service', [['entra-app-id', 'c44b4083-3bb0-49c1-b47d-974e53cbdf3c']]) +entity('ws', 'ws-0142.corp.example', 'Device', [['mde-device-id', 'dev-0142']]) +entity('ps', 'powershell.exe', 'Process') +entity('sp', 'deploy-bot', 'Service Principal', [['entra-object-id', '0b7f0c1e-1111-4222-8333-444444444444']]) +entity('quiet', 'unrelated.example', 'Domain') +activity( + 'signin', + 'signed in', + [ + ['user', 'identity'], + ['evil', 'source'], + ['app', 'target'], + ], + { UniqueTokenIdentifier: 'tokenAAAA1111bbbb' }, + '2026-10-01T10:00:00Z', +) +activity( + 'connect', + 'connection success', + [ + ['ws', 'source'], + ['ps', 'via'], + ['evil', 'target'], + ], + { DeviceId: 'DEV-0142', InitiatingProcessUniqueId: 200 }, + '2026-10-01T10:05:00Z', +) +activity( + 'sp-signin', + 'signed in', + [ + ['sp', 'identity'], + ['evil', 'source'], + ['app', 'target'], + ], + { ServicePrincipalId: 'x' }, + '2026-10-01T10:10:00Z', +) +act('entity.update', { id: 'evil', compromise: {} }) +const data = project('b', ops).data + +{ + const { gaps, answered } = boardGaps(data) + const byRule = Object.fromEntries(gaps.map(g => [g.rule, g])) + assert.deepEqual(answered, []) + // Who is the user known only by its ID: urgent, IdentityInfo by object ID, imported by the IdentityInfo mapping. + const identity = byRule['identity-info'] + assert.deepEqual( + [identity.priority, identity.entities, identity.imports_as, identity.platform], + ['high', ['user'], 'IdentityInfo', 'defender'], + ) + assert.ok(identity.query.includes(`AccountObjectId in~ (dynamic(["${oid}"]))`)) + assert.match(identity.id, /^identity-info:[0-9a-f]{12}$/) + // The device: by its Defender ID, one row per public IP. + const device = byRule['device-info'] + assert.deepEqual(device.entities, ['ws']) + assert.ok(device.query.includes('DeviceId in~ (dynamic(["dev-0142"]))') && device.query.includes('by DeviceId, PublicIP')) + // The process that connected out: its launch by device and start key, in a window around the activity. + const launch = byRule['process-launch'] + assert.deepEqual([launch.priority, launch.entities], ['high', ['dev-0142|200']]) + assert.ok(launch.query.startsWith('let start = datetime(2026-09-24T10:05:00.000Z);')) + assert.ok(launch.query.includes('in (dynamic(["dev-0142|200"]))')) + // The service principal and the app: OAuthAppInfo by app ID and service principal object ID. + const app = byRule['app-info'] + assert.deepEqual(app.entities.sort(), ['app', 'sp']) + assert.ok(app.query.includes('OAuthAppId in~') && app.query.includes('ServicePrincipalId in~')) + // Open hunts of the attack impact come last among equals; every gap has KQL. + assert.ok(gaps.some(g => g.rule === 'hunt' && g.id.startsWith('hunt:ip-everywhere:'))) + assert.ok(gaps.every(g => g.query.length > 20 && g.question && g.why)) + assert.equal(gaps[0].priority, 'high') + assert.ok(!gaps.some(g => g.entities.includes('quiet')), 'only what the attack touches') + + // An answer on the board (a source with the gap's URI) closes it for every browser; a new user opens a new gap. + const answer = (gap, extra = {}) => ({ + id: `s${++clock}`, + clock, + at: '2026-10-04T00:00:00.000Z', + actor: 'agent', + author: 'Agent', + boardId: 'b', + type: 'source.add', + payload: { + id: `answer-${gap.rule}`, + title: 'Answer', + uri: GAP_URI + gap.id, + excerpt: JSON.stringify({ gap: gap.id, rule: gap.rule, entities: gap.entities, rows: 0, note: 'nothing', ...extra }), + }, + }) + const closed = project('b', [...ops, answer(identity), answer(launch)]).data + const after = boardGaps(closed) + assert.ok(!after.gaps.some(g => g.rule === 'identity-info' || g.rule === 'process-launch')) + assert.deepEqual(after.answered.map(a => [a.rule, a.entities]).sort(), [ + ['identity-info', ['user']], + ['process-launch', ['dev-0142|200']], + ]) + const second = [...ops, answer(identity)] + second.push({ + id: 'x1', + clock: 900, + at: '2026-10-04T00:00:00.000Z', + actor: 'me', + author: 'Analyst', + boardId: 'b', + type: 'entity.add', + payload: { id: 'user2', name: 'eve@contoso.example', kind: 'User' }, + }) + second.push({ + id: 'x2', + clock: 901, + at: '2026-10-04T00:00:00.000Z', + actor: 'me', + author: 'Analyst', + boardId: 'b', + type: 'identifier.add', + payload: { + id: 'u2i', + entity_id: 'user2', + scheme: 'email', + namespace: '', + raw_value: 'eve@contoso.example', + normalized_value: 'eve@contoso.example', + }, + }) + second.push({ + id: 'x3', + clock: 902, + at: '2026-10-04T00:00:00.000Z', + actor: 'me', + author: 'Analyst', + boardId: 'b', + type: 'fact.add', + payload: { + id: 'f2', + predicate: 'signed in', + participants: [ + { entity_id: 'user2', role: 'identity' }, + { entity_id: 'evil', role: 'source' }, + ], + }, + }) + const reopened = boardGaps(project('b', second).data).gaps.find(g => g.rule === 'identity-info') + assert.deepEqual(reopened.entities, ['user2'], 'only the user not answered yet') + assert.ok(reopened.query.includes('AccountUpn in~ (dynamic(["eve@contoso.example"]))')) + assert.notEqual(reopened.id, identity.id) +} + +{ + // The neighbourhood: one hop from the user is the sign-in with its IP and app; two hops add what else touched them. + const one = neighborhood(data, 'user', 1) + assert.deepEqual(one.entities.map(e => e.id).sort(), ['app', 'evil', 'user']) + assert.deepEqual( + one.relations.map(r => [r.id, r.evidence, r.first]), + [['signin', 1, '2026-10-01T10:00:00.000Z']], + ) + assert.ok(one.entities.find(e => e.id === 'evil').compromise, 'compromise marks come along') + const two = neighborhood(data, 'user', 2) + assert.deepEqual(two.relations.map(r => r.id).sort(), ['connect', 'signin', 'sp-signin']) + assert.equal(neighborhood(data, 'nobody'), null) + assert.equal(neighborhood(data, 'user', 9).hops, 3) +} +console.log('Gaps: identity, device, app, process launch, hunts, answers, reopening; neighbourhood passed') From c5e38d9051b5ae7210d981f29eb215906508f52e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:23:44 +0000 Subject: [PATCH 3/4] Gaps and imports for service principal sign-ins, Azure and Graph activity Imports: AADManagedIdentitySignInLogs, MicrosoftGraphActivityLogs, AADGraphActivityLogs and Defender's GraphAPIAuditEvents get mappings. Graph calls are grouped by request with object IDs replaced ("GET /v1.0/users/{id}/messages"), failed ones apart; the caller is the user (by object ID) or, for app-only calls, the service principal, with the client app and, when on the board, the calling device. Gaps: for each identity the attack touches, what it did in the sources the board has no rows from for it, as original rows the mappings import: sign- ins of a service principal or managed identity, AzureActivity by Caller, Microsoft Graph and the legacy Azure AD Graph calls, from two weeks before the first evidence. An identity counts as covered for a table once evidence from it touches the identity, so a dropped export closes the gap too. The summarising hunts for these (sp-signins, sp-azure, sp-graph) are no longer offered as gaps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK --- README.md | 3 +- app/tables.py | 111 ++++++++++++++++++++++++++++++ tests/test_relations.py | 56 +++++++++++++++ web/e2e/workflows.spec.ts | 11 ++- web/src/gaps.ts | 140 ++++++++++++++++++++++++++++++++++++-- web/tests/gaps.cjs | 58 +++++++++++++++- 6 files changed, 370 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4a68052..8f350ab 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,7 @@ On connect the server sends binding **working rules** to the agent (also availab - **What is this device, who uses it** (`DeviceInfo`): Defender and Entra device IDs, the public IPs it reported from (the legitimate egress) and the logged-on users already on the board. - **Which app is this** (`OAuthAppInfo`): the app behind a service principal or app ID, with privilege level and permissions in the row. - **Where did this process come from** (`DeviceProcessEvents` by process start key and device): the launch, its command line and parent, for activities whose process was never launched on the board. +- **What did this identity do** in the sources the board has no rows from for it, as original rows (two weeks before the first evidence, for the baseline): **sign-ins** of a service principal (`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. - **Open hunts** of the attack impact for tables nothing was imported from yet (session tokens, sign-in sessions, the attacker's IPs …). 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/`, 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. @@ -419,7 +420,7 @@ Every row becomes its own evidence item with its timestamp and locator; the same 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. -- **Recognised tables** with a curated mapping: `DeviceProcessEvents`, `DeviceNetworkEvents`, `DeviceFileEvents`, `DeviceLogonEvents`, `DeviceRegistryEvents`, `DeviceImageLoadEvents`, `DeviceEvents`, `IdentityLogonEvents`, `IdentityDirectoryEvents`, `IdentityQueryEvents`, `EntraIdSignInEvents`, `EntraIdSpnSignInEvents`, `CloudAppEvents`, `UrlClickEvents`, `EmailEvents`, `EmailUrlInfo`, `EmailAttachmentInfo`, `AlertEvidence`; Sentinel `SigninLogs`, `AADNonInteractiveUserSignInLogs`, `AADServicePrincipalSignInLogs`, `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. +- **Recognised tables** with a curated mapping: `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. - **Mapping:** every row becomes evidence for an activity whose participants carry roles, for example in `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. - **One process launch, one entity:** the Windows process start key (`ProcessUniqueId`, `InitiatingProcessUniqueId`, with the `DeviceId`) joins a process seen with its path in `DeviceProcessEvents` and without it in `DeviceNetworkEvents`. It is matched only after the stronger IDs, so a board imported before keeps its entities and a repeated import adds nothing. - **The same thing stays one entity:** devices, accounts, files, apps and Azure resources are stored with their identifiers (DeviceId, AadDeviceId, Entra object ID, SID, SHA-256/SHA-1/MD5, app ID, resource ID, FQDN, UPN, IP) and found by them on later imports, so `j.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. diff --git a/app/tables.py b/app/tables.py index 037de5c..085ea6c 100644 --- a/app/tables.py +++ b/app/tables.py @@ -387,6 +387,45 @@ def location(fields=("LocationDetails.countryOrRegion", "Location", "Country")) return Part("source", "Location", fields) +# A path segment that names one object: a GUID, a long ID, a number or a user's address. +GRAPH_ID = re.compile(r"/(?:[0-9a-fA-F]{8}-[0-9a-fA-F-]{27}|[^/@]+@[^/]+|[A-Za-z0-9+=_-]{20,}|\d+)(?=/|$)") + + +def graph_operation(row: Row, g: Callable) -> str: + """ "GET /v1.0/users/{id}/messages": the request with object IDs replaced, so the same call on many objects is one + activity (its evidence keeps every original row). A non-2xx answer is named, so failed attempts stand apart.""" + uri = text(g("RequestUri")) + path = re.sub(r"^[a-z]+://[^/]+", "", uri).split("?")[0] or uri + operation = f"{text(g('RequestMethod')).upper()} {GRAPH_ID.sub('/{id}', path)}".strip() + status = text(g("ResponseStatusCode")) + return operation + (f" (failed {status})" if status and not status.startswith("2") else "") + + +def graph_parts(ip: str, resource: str) -> tuple[Part, ...]: + """Who called (a user, or a service principal for app-only calls), with which client app, from where, on what.""" + return ( + Part("identity", "User", ("UserId",), (Ident("UserId", namespace="entra-object-id"),)), + Part( + "identity", + "Service Principal", + ("ServicePrincipalId",), + (Ident("ServicePrincipalId", namespace="entra-object-id"), Ident("AppId", namespace="entra-app-id")), + ), + # The client app of a delegated call (an app-only call is the service principal itself). + Part( + "tool", + "Service", + ("AppId",), + (Ident("AppId", namespace="entra-app-id"),), + name=lambda r, g: "" if text(g("ServicePrincipalId")) else text(g("AppId")), + ), + Part("source", "IP", (ip,), (Ident(ip, "ip"),)), + # The calling device (its Entra device ID), when it is on the board already. + Part("via", "Device", ("DeviceId",), (Ident("DeviceId", namespace="entra-device-id"),), lower=True, create=False), + Part("target", "Service", ("RequestUri",), name=lambda r, g: resource), + ) + + MAPPINGS: tuple[Mapping, ...] = ( # ---------------------------------------------------------------- Defender XDR (and the same tables in Sentinel) Mapping( @@ -706,6 +745,32 @@ def location(fields=("LocationDetails.countryOrRegion", "Location", "Country")) operation=lambda r, g: f"alert evidence ({text(g('EvidenceRole')).lower() or 'related'})", locator=("AlertId", "ReportId"), ), + Mapping( + "GraphAPIAuditEvents", + "Defender XDR", + ("RequestMethod", "RequestUri", "ResponseStatusCode", "IpAddress", "UniqueTokenIdentifier"), + ( + Part("identity", "User", ("AccountObjectId",), (Ident("AccountObjectId", namespace="entra-object-id"),)), + Part( + "identity", + "Service Principal", + ("ServicePrincipalId",), + (Ident("ServicePrincipalId", namespace="entra-object-id"), Ident("ApplicationId", namespace="entra-app-id")), + ), + Part( + "tool", + "Service", + ("ApplicationId",), + (Ident("ApplicationId", namespace="entra-app-id"),), + name=lambda r, g: "" if text(g("ServicePrincipalId")) else text(g("ApplicationId")), + ), + Part("source", "IP", ("IpAddress",), (Ident("IpAddress", "ip"),)), + Part("target", "Service", ("RequestUri",), name=lambda r, g: "Microsoft Graph"), + ), + operation=graph_operation, + locator=("ReportId", "RequestId"), + details=(("status", "ResponseStatusCode"), ("scopes", "Scopes")), + ), # Inventory: what a device, user or app is, for entities already on the board. A DeviceInfo row with a public IP # says where the device was online (the egress a baseline needs); the others only add IDs and names. Mapping( @@ -902,6 +967,52 @@ def location(fields=("LocationDetails.countryOrRegion", "Location", "Country")) ("session", "UniqueTokenIdentifier"), ), ), + # Managed identities sign in like service principals; their own table says which identity and resource. + Mapping( + "AADManagedIdentitySignInLogs", + "Sentinel", + ("ServicePrincipalName", "ServicePrincipalId", "ResourceDisplayName", "ResultType", "ManagedServiceIdentity"), + ( + Part( + "identity", + "Managed Identity", + ("ServicePrincipalName",), + (Ident("ServicePrincipalId", namespace="entra-object-id"), Ident("AppId", namespace="entra-app-id")), + ), + Part("source", "IP", ("IPAddress",), (Ident("IPAddress", "ip"),)), + Part( + "target", + "Service", + ("ResourceDisplayName",), + (Ident("ResourceIdentity", namespace="entra-app-id"), Ident("ResourceServicePrincipalId", namespace="entra-object-id")), + ), + ), + operation=sign_in, + time=("TimeGenerated", "CreatedDateTime"), + locator=("CorrelationId", "Id"), + details=(("credential", "ClientCredentialType"), ("session", "UniqueTokenIdentifier")), + ), + # Microsoft Graph and the legacy Azure AD Graph: what an identity read and changed in the directory, mail, files. + Mapping( + "MicrosoftGraphActivityLogs", + "Sentinel", + ("RequestMethod", "RequestUri", "ResponseStatusCode", "IPAddress", "SignInActivityId"), + graph_parts("IPAddress", "Microsoft Graph"), + operation=graph_operation, + time=("TimeGenerated",), + locator=("RequestId",), + details=(("status", "ResponseStatusCode"), ("user agent", "UserAgent"), ("scopes", "Scopes"), ("roles", "Roles")), + ), + Mapping( + "AADGraphActivityLogs", + "Sentinel", + ("RequestMethod", "RequestUri", "ResponseStatusCode", "CallerIpAddress", "SignInActivityId"), + graph_parts("CallerIpAddress", "Azure AD Graph"), + operation=graph_operation, + time=("TimeRequested", "TimeGenerated"), + locator=("RequestId",), + details=(("status", "ResponseStatusCode"), ("user agent", "UserAgent"), ("scopes", "Scopes")), + ), Mapping( "AuditLogs", "Sentinel", diff --git a/tests/test_relations.py b/tests/test_relations.py index fdd6d00..7e7d779 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -321,6 +321,62 @@ def test_a_board_imported_before_gets_nothing_twice(self): self.assertFalse([d for d in again if d["type"] in ("entity.add", "fact.add", "assertion.add") and d["payload"]["id"] not in known]) +class GraphActivity(unittest.TestCase): + """Microsoft Graph, Azure AD Graph and Defender's Graph audit rows: who called what, grouped by request.""" + + def test_calls_on_many_objects_are_one_activity_per_request(self): + board, index = str(uuid4()), {"entities": [], "facts": [], "sources": []} + run(board, [dict(r) for r in SIGNIN], index) # j.doe, object ID 11111111-… + base = { + "TimeGenerated": "2026-09-28T10:00:00Z", + "RequestMethod": "GET", + "ResponseStatusCode": "200", + "IPAddress": "203.0.113.7", + "SignInActivityId": "tokenAAAA1111bbbb", + "UserId": "11111111-2222-3333-4444-555555555555", + "AppId": "d3590ed6-52b3-4102-aeff-aad2292ab01c", + "ServicePrincipalId": "", + } + rows = [ + {**base, "RequestId": f"r{i}", "RequestUri": f"https://graph.microsoft.com/v1.0/users/{user}/messages?$top=50"} + for i, user in enumerate(["a@corp.example", "b@corp.example", "0b7f0c1e-1111-4222-8333-444444444444"]) + ] + [ + { + **base, + "RequestId": "r9", + "RequestUri": "https://graph.microsoft.com/v1.0/applications", + "RequestMethod": "POST", + "ResponseStatusCode": "403", + } + ] + detection, drafts, summary = run(board, rows, index) + self.assertEqual(detection.table, "MicrosoftGraphActivityLogs") + operations = sorted(d["payload"]["predicate"] for d in drafts if d["type"] == "fact.add") + self.assertEqual(operations, ["GET /v1.0/users/{id}/messages", "POST /v1.0/applications (failed 403)"]) + self.assertEqual(summary["evidence"], 4) + user = next(e["id"] for e in index["entities"] if e["kind"] == "User") + read = next(d["payload"] for d in drafts if d["type"] == "fact.add" and d["payload"]["predicate"].startswith("GET")) + names = {e["id"]: e["name"] for e in index["entities"]} + self.assertIn({"entity_id": user, "role": "identity"}, read["participants"], "the user from the sign-in logs, by object ID") + self.assertEqual( + sorted(names[p["entity_id"]] for p in read["participants"] if p["role"] in ("target", "source")), + ["203.0.113.7", "Microsoft Graph"], + ) + + def test_tables_are_told_apart(self): + from app.tables import detect + + graph = {"RequestMethod", "RequestUri", "ResponseStatusCode", "SignInActivityId"} + self.assertEqual(detect(graph | {"IPAddress", "UserId"}).table, "MicrosoftGraphActivityLogs") + self.assertEqual(detect(graph | {"CallerIpAddress", "UserId"}).table, "AADGraphActivityLogs") + self.assertEqual( + detect({"RequestMethod", "RequestUri", "ResponseStatusCode", "IpAddress", "UniqueTokenIdentifier"}).table, "GraphAPIAuditEvents" + ) + sp = {"ServicePrincipalName", "ServicePrincipalId", "ResourceDisplayName", "ResultType"} + self.assertEqual(detect(sp | {"ManagedServiceIdentity"}).table, "AADManagedIdentitySignInLogs") + self.assertEqual(detect(sp).table, "AADServicePrincipalSignInLogs") + + class RowKeys(unittest.TestCase): """A row already on the board adds no evidence again, also when a newer mapping sees other entities in it.""" diff --git a/web/e2e/workflows.spec.ts b/web/e2e/workflows.spec.ts index f087fd9..be59f26 100644 --- a/web/e2e/workflows.spec.ts +++ b/web/e2e/workflows.spec.ts @@ -1632,9 +1632,16 @@ test('agents: get_gaps names what is missing with KQL, submit_gap_rows imports t gap_id: answer.next_gap.id, body: { rows: [], note: 'first-party app, not in OAuthAppInfo' }, }) - expect([empty.rows, empty.remaining]).toEqual([0, 0]) + expect([empty.rows, empty.remaining]).toEqual([0, 3]) + // Left: what the user did in Azure and Graph, as original rows (low: nothing is marked compromised yet). const after = await call('GET', '/gaps') - expect([after.total, after.answered]).toEqual([0, 2]) + expect([after.total, after.answered]).toEqual([3, 2]) + expect(after.gaps.map((g: any) => [g.rule, g.priority]).sort()).toEqual([ + ['aad-graph-activity', 'low'], + ['azure-activity', 'low'], + ['graph-activity', 'low'], + ]) + expect(after.gaps.find((g: any) => g.rule === 'azure-activity').query).toContain(`Caller in~ (dynamic(["${oid}", "j.doe@corp.example"]))`) const board = await graph() expect(board.entities.find((e: any) => e.id === user).name).toBe('j.doe@corp.example') const holds = board.facts.find((f: any) => f.predicate === 'holds roles') diff --git a/web/src/gaps.ts b/web/src/gaps.ts index ae8b96a..c54c65c 100644 --- a/web/src/gaps.ts +++ b/web/src/gaps.ts @@ -27,7 +27,17 @@ export type Gap = { /** The built-in mapping that imports the result rows; without one the rows are kept as a source. */ imports_as?: string } -export type GapRule = 'identity-info' | 'device-info' | 'app-info' | 'process-launch' | 'hunt' +export type GapRule = + | 'identity-info' + | 'device-info' + | 'app-info' + | 'process-launch' + | 'sp-signins' + | 'mi-signins' + | 'azure-activity' + | 'graph-activity' + | 'aad-graph-activity' + | 'hunt' export type AnsweredGap = { id: string; rule: string; entities: string[]; rows: number; note: string; source_id: string; at: string } export type GapReport = { gaps: Gap[]; answered: AnsweredGap[] } @@ -82,12 +92,12 @@ function relevant(data: Pick, impact: Impact): } /** UTC times of the evidence of these facts, for a query window that also finds what happened just before. */ -function windowOf(facts: Fact[]): string { +function windowOf(facts: Fact[], daysBefore = 7): string { const times = facts.flatMap(f => f.assertions.map(a => a.valid_from).filter((t): t is string => !!t && !Number.isNaN(Date.parse(t)))) if (!times.length) return 'let start = ago(30d);\nlet end = now();\n' times.sort() const day = 86_400_000 - return `let start = datetime(${new Date(Date.parse(times[0]) - 7 * day).toISOString()});\nlet end = datetime(${new Date(Date.parse(times.at(-1)!) + day).toISOString()});\n` + return `let start = datetime(${new Date(Date.parse(times[0]) - daysBefore * day).toISOString()});\nlet end = datetime(${new Date(Date.parse(times.at(-1)!) + day).toISOString()});\n` } export function boardGaps(data: GraphData, impact: Impact = analyzeImpact(data)): GapReport { @@ -228,10 +238,130 @@ export function boardGaps(data: GraphData, impact: Impact = analyzeImpact(data)) ) } - // The hunts the attack impact suggests for tables nothing was imported from yet. + // What the identities the attack touches did, per source the board has no rows from for them: their sign-ins, Azure + // control-plane operations and Graph calls, as original rows the built-in mappings import. Each identity counts as + // covered for a table once evidence from it touches the identity (so a dropped export closes the gap too), or once a + // gap for it was answered. Two weeks before the first evidence, for the baseline before the compromise. + const ends = (fact: Fact) => (fact.participants?.length ? fact.participants.map(p => p.entity_id) : [fact.subject_id, fact.object_id]) + const tablesOf = new Map>() + const factsOf = new Map() + for (const fact of data.facts) { + const tables = new Set(fact.assertions.map(a => /^([A-Za-z]\w+)/.exec(a.locator ?? '')?.[1]).filter((t): t is string => !!t)) + for (const id of ends(fact)) { + if (!scope.has(id)) continue + const set = tablesOf.get(id) ?? new Set() + for (const table of tables) set.add(table) + tablesOf.set(id, set) + factsOf.set(id, [...(factsOf.get(id) ?? []), fact]) + } + } + const lacks = (rule: GapRule, tables: string[], pattern: RegExp, usable: (e: Entity) => boolean) => + candidates(pattern, rule, e => usable(e) && !tables.some(t => tablesOf.get(e.id)?.has(t))) + const level = (entities: Entity[], quiet: Gap['priority'] = 'medium'): Gap['priority'] => + entities.some(e => marked(e.id)) ? 'high' : impact.seeds.length ? quiet : 'low' + const activity = ( + rule: GapRule, + entities: Entity[], + gap: Pick & { + filter: string + table: string + time?: string + quiet?: Gap['priority'] + }, + ) => { + if (!entities.length || !gap.filter) return + const facts = [...new Set(entities.flatMap(e => factsOf.get(e.id) ?? []))] + add( + { + rule, + priority: level(entities, gap.quiet), + question: gap.question, + why: gap.why, + entities: entities.map(e => e.id), + tables: gap.tables, + platform: 'sentinel', + imports_as: gap.imports_as, + query: `${windowOf(facts, 14)}${gap.table}\n| where ${gap.time ?? 'TimeGenerated'} between (start .. end)\n| where ${gap.filter}\n| order by ${gap.time ?? 'TimeGenerated'} asc\n| take 20000`, + }, + entities.map(e => e.id), + ) + } + const hasIds = (e: Entity) => ids(e, 'entra-object-id').length > 0 || ids(e, 'entra-app-id').length > 0 + const filterBy = (pairs: [string, string[]][]) => + pairs + .filter(([, values]) => values.length) + .map(([column, values]) => `${column} in~ (${list([...new Set(values)].slice(0, MAX_VALUES))})`) + .join(' or ') + const principals = lacks('sp-signins', ['AADServicePrincipalSignInLogs', 'EntraIdSpnSignInEvents'], /service principal/i, hasIds) + activity('sp-signins', principals, { + question: `How did ${principals.length === 1 ? 'this service principal' : `these ${principals.length} service principals`} sign in: from where, with which credential, to what?`, + why: 'Every sign-in with its IP, credential (key ID or certificate) and resource: an IP or key nobody knows is the next pivot, a key added during the attack is a backdoor.', + tables: ['AADServicePrincipalSignInLogs'], + imports_as: 'AADServicePrincipalSignInLogs', + table: 'AADServicePrincipalSignInLogs', + filter: filterBy([ + ['ServicePrincipalId', principals.flatMap(e => ids(e, 'entra-object-id'))], + ['AppId', principals.flatMap(e => ids(e, 'entra-app-id'))], + ]), + }) + const managed = lacks('mi-signins', ['AADManagedIdentitySignInLogs'], /managed identity/i, hasIds) + activity('mi-signins', managed, { + question: `Which tokens did ${managed.length === 1 ? 'this managed identity' : `these ${managed.length} managed identities`} get, for which resources?`, + why: 'A managed identity used from where it does not run (another IP, another resource) means its token or its host was taken over.', + tables: ['AADManagedIdentitySignInLogs'], + imports_as: 'AADManagedIdentitySignInLogs', + table: 'AADManagedIdentitySignInLogs', + filter: filterBy([ + ['ServicePrincipalId', managed.flatMap(e => ids(e, 'entra-object-id'))], + ['AppId', managed.flatMap(e => ids(e, 'entra-app-id'))], + ]), + }) + const upnOf = (e: Entity) => e.identifiers.filter(i => i.scheme === 'email').map(i => i.normalized_value) + const identity = /\buser\b|account|person|service principal|managed identity/i + const callers = lacks('azure-activity', ['AzureActivity'], identity, e => ids(e, 'entra-object-id').length > 0 || upnOf(e).length > 0) + activity('azure-activity', callers, { + question: `What did ${callers.length === 1 ? 'this identity' : `these ${callers.length} identities`} do in Azure?`, + why: 'Control-plane operations (Caller is the UPN of a user, the object ID of a service principal): listKeys and listCredentials expose secrets, role assignments and new credentials are persistence, deletes are impact.', + tables: ['AzureActivity'], + imports_as: 'AzureActivity', + table: 'AzureActivity', + filter: filterBy([['Caller', [...callers.flatMap(e => ids(e, 'entra-object-id')), ...callers.flatMap(upnOf)]]]), + }) + const graphFilter = (entities: Entity[]) => + filterBy([ + ['UserId', entities.filter(e => !/service principal|managed identity/i.test(e.kind)).flatMap(e => ids(e, 'entra-object-id'))], + [ + 'ServicePrincipalId', + entities.filter(e => /service principal|managed identity/i.test(e.kind)).flatMap(e => ids(e, 'entra-object-id')), + ], + ['AppId', entities.filter(e => /service principal|managed identity/i.test(e.kind)).flatMap(e => ids(e, 'entra-app-id'))], + ]) + const graphCallers = lacks('graph-activity', ['MicrosoftGraphActivityLogs', 'GraphAPIAuditEvents'], identity, hasIds) + activity('graph-activity', graphCallers, { + question: `What did ${graphCallers.length === 1 ? 'this identity' : `these ${graphCallers.length} identities`} read and change through Microsoft Graph?`, + why: 'Directory, mail, files and app registrations through Graph never show in AzureActivity: enumeration (users, groups, service principals), mailbox reads and new app credentials. Calls are grouped by request with IDs replaced; failed ones stand apart.', + tables: ['MicrosoftGraphActivityLogs'], + imports_as: 'MicrosoftGraphActivityLogs', + table: 'MicrosoftGraphActivityLogs', + filter: graphFilter(graphCallers), + }) + const legacy = lacks('aad-graph-activity', ['AADGraphActivityLogs'], identity, hasIds) + activity('aad-graph-activity', legacy, { + question: `Did ${legacy.length === 1 ? 'this identity' : `these ${legacy.length} identities`} use the legacy Azure AD Graph?`, + why: 'Attack tools (AADInternals, ROADrecon) still enumerate the directory through Azure AD Graph, which few defenders watch.', + tables: ['AADGraphActivityLogs'], + imports_as: 'AADGraphActivityLogs', + table: 'AADGraphActivityLogs', + filter: graphFilter(legacy), + quiet: 'low', + }) + + // The hunts the attack impact suggests for tables nothing was imported from yet (not those the identity gaps above + // ask with original rows). + const asRows = new Set(['sp-signins', 'sp-azure', 'sp-graph']) const urgentHunts = new Set(['session-replay', 'signin-session', 'ip-everywhere', 'credential-usage']) for (const hunt of huntingQueries(data, impact)) { - if (hunt.imported) continue + if (hunt.imported || asRows.has(hunt.id)) continue const id = `hunt:${hunt.id}:${hash([hunt.query])}` if (isCovered('hunt', id)) continue gaps.push({ diff --git a/web/tests/gaps.cjs b/web/tests/gaps.cjs index 578e513..63ae13b 100644 --- a/web/tests/gaps.cjs +++ b/web/tests/gaps.cjs @@ -94,6 +94,60 @@ const data = project('b', ops).data assert.equal(gaps[0].priority, 'high') assert.ok(!gaps.some(g => g.entities.includes('quiet')), 'only what the attack touches') + // What the identities did, as original rows: sign-ins of the service principal, Azure operations and Graph calls of + // the user and the service principal, two weeks before the first evidence; the summarising hunts for them are left out. + const signins = byRule['sp-signins'] + assert.deepEqual([signins.entities, signins.imports_as, signins.platform], [['sp'], 'AADServicePrincipalSignInLogs', 'sentinel']) + assert.ok(signins.query.includes('ServicePrincipalId in~ (dynamic(["0b7f0c1e-1111-4222-8333-444444444444"]))')) + assert.ok(signins.query.startsWith('let start = datetime(2026-09-17T10:10:00.000Z);') && signins.query.endsWith('| take 20000')) + assert.deepEqual(byRule['azure-activity'].entities.sort(), ['sp', 'user']) + assert.ok(byRule['azure-activity'].query.includes(`Caller in~ (dynamic(["${oid}", "0b7f0c1e-1111-4222-8333-444444444444"]))`)) + const graphGap = byRule['graph-activity'] + assert.ok(graphGap.query.includes(`UserId in~ (dynamic(["${oid}"]))`) && graphGap.query.includes('ServicePrincipalId in~')) + assert.equal(byRule['aad-graph-activity'].priority, 'high', 'the service principal signed in from the attacker IP: derived compromised') + assert.ok(!gaps.some(g => /^hunt:(sp-signins|sp-azure|sp-graph):/.test(g.id))) + // Rows from a table on the board cover the identity for it, also from a dropped export. + { + const more = [...ops] + more.push({ + id: 'z1', + clock: 950, + at: '2026-10-04T00:00:00.000Z', + actor: 'me', + author: 'Analyst', + boardId: 'b', + type: 'fact.add', + payload: { + id: 'arm', + predicate: 'listkeys storage', + participants: [ + { entity_id: 'user', role: 'identity' }, + { entity_id: 'evil', role: 'source' }, + ], + }, + }) + more.push({ + id: 'z2', + clock: 951, + at: '2026-10-04T00:00:00.000Z', + actor: 'me', + author: 'Analyst', + boardId: 'b', + type: 'assertion.add', + payload: { + id: 'arm-e', + fact_id: 'arm', + stance: 'supports', + valid_from: '2026-10-01T11:00:00Z', + note: '{}', + locator: 'AzureActivity CorrelationId=c1', + source_id: null, + }, + }) + const covered = boardGaps(project('b', more).data).gaps.find(g => g.rule === 'azure-activity') + assert.deepEqual(covered.entities, ['sp'], 'the user has AzureActivity rows now') + } + // An answer on the board (a source with the gap's URI) closes it for every browser; a new user opens a new gap. const answer = (gap, extra = {}) => ({ id: `s${++clock}`, @@ -182,4 +236,6 @@ const data = project('b', ops).data assert.equal(neighborhood(data, 'nobody'), null) assert.equal(neighborhood(data, 'user', 9).hops, 3) } -console.log('Gaps: identity, device, app, process launch, hunts, answers, reopening; neighbourhood passed') +console.log( + 'Gaps: identity, device, app, process launch, sign-ins, Azure and Graph activity, hunts, answers, reopening; neighbourhood passed', +) From 45a882f28fd912f4a4477ae194d1389689e15cf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:32:07 +0000 Subject: [PATCH 4/4] Release v0.6.2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK --- README.md | 6 +++--- app/main.py | 4 ++-- deploy/publish-multiarch.sh | 2 +- web/package-lock.json | 4 ++-- web/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8f350ab..99c710a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Notes: - **Exactly one instance.** The relay keeps the list of connected browsers in memory; with several instances, analysts on different instances would not see each other. Do not scale out. - **Web sockets must be on**, otherwise the board stays offline. **Always On** prevents the app from idling and dropping connections. -- Prefer a pinned version (`k3mpaxl/factgraph:0.6.1`) over `latest` during an incident, so a restart never changes the version. +- Prefer a pinned version (`k3mpaxl/factgraph:0.6.2`) over `latest` during an incident, so a restart never changes the version. - App Service **HTTP logging** is off by default. If you enable it, it records request URLs, which contain board IDs. - **WebSocket limits per plan:** every open FactGraph tab holds one connection. App Service allows 5 per instance on **F1 (Free)**, 35 on Shared and 350 from **B1**; above the limit, browsers get HTTP 429 and stay offline. Use B1 or higher. Browsers send a heartbeat, and the server closes connections that stay silent (closed laptops, changed networks), so slots are freed without restarting the app. - **App Service Authentication** (Entra ID sign-in) can be put in front of the UI. Agents and scripts then also need an Entra token, and if the incident involves your own tenant, an identity provider you cannot trust is no protection. IP access restrictions are often the better choice there. @@ -503,8 +503,8 @@ cd web && npm run build && DOCS_SCREENSHOTS=1 npx playwright test e2e/docs-scree - **Publish Docker image** builds the image for `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). ```bash -git tag v0.6.1 -git push origin v0.6.1 +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). diff --git a/app/main.py b/app/main.py index cc6ea39..2453c27 100644 --- a/app/main.py +++ b/app/main.py @@ -84,9 +84,9 @@ Times: ISO 8601; a time without a zone is UTC. Compromise marks you set are "suspected" unless you pass level "confirmed" and the evidence proves it. Tool profile: this server shows a compact agent tool set by default; with FACTGRAPH_MCP_TOOLS=full it exposes one rest_ tool per REST endpoint instead. """ -mcp = FastMCP("FactGraph Browser Boards", version="0.6.1", instructions=MCP_INSTRUCTIONS) +mcp = FastMCP("FactGraph Browser Boards", version="0.6.2", instructions=MCP_INSTRUCTIONS) mcp_app = mcp.http_app(path="/") -app = FastAPI(title="FactGraph API", version="0.6.1", lifespan=mcp_app.lifespan) +app = FastAPI(title="FactGraph API", version="0.6.2", lifespan=mcp_app.lifespan) @dataclass diff --git a/deploy/publish-multiarch.sh b/deploy/publish-multiarch.sh index 902f09c..c1f3893 100755 --- a/deploy/publish-multiarch.sh +++ b/deploy/publish-multiarch.sh @@ -2,7 +2,7 @@ set -euo pipefail image="${FACTGRAPH_IMAGE:-k3mpaxl/factgraph}" -version="${FACTGRAPH_VERSION:-0.6.1}" +version="${FACTGRAPH_VERSION:-0.6.2}" docker buildx build \ --platform linux/amd64,linux/arm64 \ diff --git a/web/package-lock.json b/web/package-lock.json index 6215818..1b8da3a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "factgraph-web", - "version": "0.6.1", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "factgraph-web", - "version": "0.6.1", + "version": "0.6.2", "dependencies": { "@xyflow/react": "^12.12.0", "d3-force": "^3.0.0", diff --git a/web/package.json b/web/package.json index 5e707d7..7339230 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "factgraph-web", "private": true, - "version": "0.6.1", + "version": "0.6.2", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1",