Skip to content

v0.6.2: gaps for agents, relationships from inventory, Graph and service principal activity - #13

Merged
k3mpaxl merged 4 commits into
mainfrom
claude/inspiring-sagan-y7zy4q
Oct 18, 2026
Merged

k3mpaxl merged 4 commits into
mainfrom
claude/inspiring-sagan-y7zy4q

Conversation

@k3mpaxl

@k3mpaxl k3mpaxl commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Makes FactGraph tell a connected agent precisely what is missing, with the KQL to fetch it, and take the answer through one interface. Also adds the imports that make those answers land in the graph as relationships.

Gaps for agents (b76fa02, c5e38d9)

  • get_gaps (GET /api/boards/{id}/gaps): what the board still needs, most important first. Each gap has a question, the reason, filled-in KQL, the platform to run it on (Defender advanced hunting or Sentinel) and the mapping that imports the rows. Computed in the browser (web/src/gaps.ts) like every board read.
    • 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).
    • Per identity the attack touches, what it did in sources the board has no rows from for it: service principal and managed identity sign-ins, AzureActivity by Caller, Microsoft Graph and the legacy Azure AD Graph. An identity counts as covered once evidence from that table touches it, so a dropped export closes the gap too.
    • Open hunts of the attack impact for tables nothing was imported from.
    • Scope: what the attack touches once something is marked (attacker activities, anything that met a marked entity, pivots, impacted resources), otherwise every connected entity.
  • 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, with a note) as a source with the URI factgraph:gap/<id>. 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?hops=): one entity's surroundings up to three hops instead of the whole board.
  • Agent profile: 31 tools, about 10,600 tokens of descriptions (budget raised deliberately from 9,900 to 10,700). The server instructions tell agents to work through get_gaps until it is empty.

Relationships from inventory (6a482b3)

  • Mapping parts can read a list column (one participant per element).
  • IdentityInfo becomes holds roles: the user with active and PIM-eligible Entra roles as role entities, so who else holds a role is one hop away.
  • DeviceInfo adds the logged-on users already on the board (joined by the on-premises SID that IdentityInfo adds); unknown users are not created.

Graph and managed identity imports (c5e38d9)

  • New mappings: MicrosoftGraphActivityLogs, AADGraphActivityLogs, Defender GraphAPIAuditEvents, AADManagedIdentitySignInLogs.
  • Graph calls are grouped by request with object IDs replaced (GET /v1.0/users/{id}/messages); failed calls are kept apart.
  • The caller is the user (by object ID) or the service principal for app-only calls, with the client app and the calling device when it is on the board. The catalog coverage test checks every entity-ID join of the new tables.

Release (45a882f)

Version 0.6.2 in the backend, frontend, publish script and README.

Tests

  • Backend: 77 tests (list columns, roles, logged-on users, Graph grouping, table detection, tool contract).
  • Frontend unit tests incl. new tests/gaps.cjs (every gap kind, coverage by evidence and by answers, reopening for new entities, neighbourhood); format checks, build, performance budgets.
  • Browser workflows: 30 passed and 1 skipped (README screenshots) in the full run. The new agent test (MCP get_gaps → submit_gap_rows with an IdentityInfo row → roles on the board → empty answer → remaining activity gaps → get_neighbors → stale gap refused) failed in that run on an outdated gap count; after updating its expectations it passes on its own.

Generated by Claude Code

… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK
…rface

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/<id>: 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK
…vity

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkeMnAg6DWQ8xZucm51CzK
@k3mpaxl
k3mpaxl merged commit 5247bbc into main Oct 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants