Repository navigation
feat(bec): BEC case workflow with attacker IP analysis, blast radius and background containment - #490
Merged
Merged
Conversation
Zacgoose
force-pushed
the
preview/bec-case-workflow
branch
7 times, most recently
from
September 6, 2026 10:32
665a892 to
0539fb0
Compare
Zacgoose
marked this pull request as ready for review
September 7, 2026 06:29
Zacgoose
marked this pull request as draft
September 7, 2026 11:10
Zacgoose
force-pushed
the
preview/bec-case-workflow
branch
3 times, most recently
from
September 9, 2026 09:12
25a457f to
ba5dd13
Compare
Zacgoose
force-pushed
the
preview/bec-case-workflow
branch
from
September 14, 2026 16:05
ba5dd13 to
380aa7e
Compare
…estigation Push-BECRun computed and stored several fields nothing reads, and paid Graph calls for them: LastSuspectUserLogon (a second sign-in query for the row the top-50 query already returns first), TenantLastSignIns (50 unrelated users' sign-ins that only ever landed in an evidence CSV), HuntressFeedAvailable, TransportRuleTotal, DefenderAvailable, AcceptedDomains and ContentPolicy. All are gone, along with ResultsBytes on the run row, Score.Version and the ResultsBlob branch for rows no version ever wrote. The `if ($Scope -eq 'Full')` block was guarded by a constant; the block is now unconditional and the queue item no longer carries a Scope. The Get-Mailbox preflight is gone too: the inventory's own error already reaches the classifier through $Mark and becomes the same skipped marker. The malicious-app catalog was loaded twice per run. The orchestrator now takes the merged rogue-app feed once and shares it with the consent collector, so AddedApps and MaliciousSPs match the same list (Huntress entries included). The feed's table cache is dropped; the per-worker memo stays. The heuristics loader loses its memo/hot-reload, the always-true catalog flag and four JSON keys nothing read. Also: the async-deployment row now carries the tenant, every `+=` in the collectors is gone, and the small helpers (case id, run steps, collector result count, score weight) are one expression each.
Set-CIPPTransportRuleState, Disable-CIPPMailboxApp, Set-CIPPServicePrincipalState, Set-CIPPCASMailboxProtocols and Set-CIPPEntraDeviceState were each called from exactly one arm of the containment dispatcher, always with the same polarity, and each repeated the try/catch, normalisation and logging the dispatcher already does per action. The Exchange and Graph calls now sit in those arms; registered devices go through the existing Set-CIPPDeviceState. Remove-CIPPUserOAuthGrant had the same loop twice (grants, app-role assignments); it is one loop over two sets. The dispatcher reads parameters by member access (case-insensitive on both hashtables and deserialised objects) instead of rebuilding a key map, and offers -Redacted so the webhook action and the risky-users alert no longer carry their own copy of the password-redaction loop. The dead "Failed" state check on session revocation is gone: the helper throws on failure.
ListBECEvidence had no caller: the runs hub and the case page both build the package through ExecBECEvidenceExport so the browser-rendered PDFs can be included. ExecBECBulkCheck's Selection=ForeignSuccessfulSignIns sweep and the userId/userid spellings had no caller either, and ExecBECCheck's GET overwrite=true existed for a page this branch deleted. ListBECRemediationActions returns the array directly, as list endpoints do; the containment drawer and the alert form consume it as such. ExecBECReport's one-arm switch is an if, ListBECPhishingSpread no longer loads the heuristics file for a default the trace helper already has, and its days parameter is parsed inside the try so a bad value is a formatted error rather than a 500. ExecBECRemediate tolerates a user id that resolves to no UPN. Docs: the evidence package holds report-full.pdf and report-summary.pdf, and the reports-page download includes both. The stray root node_modules ignore is removed. The containment drawer (which consumes the catalog) also reads each action's ParameterName from that catalog instead of a hand-kept id map, and builds its picker options once instead of twice.
The triage header counted every inbox rule as suspicious while the evidence groups counted only the flagged ones; it now reads the same becFindingFlags predicates. The PDF report imports the same predicates, the objective groups and buildBecTimeline instead of carrying its own copies of the score fallback (unreachable: every run stores a server-side score), the objective breakdown and a 14-source event builder, so the page and the report agree. The report's hardcoded "7 days" strings follow the run's analysis window, and it reads RiskState.Listed, the field the collector actually emits. The timeline util gains the Intune-enrolment source the report had. CippBecRunStatusCard's completed branch could never render (the case page shows the triage header in that state); it and its props are gone. The two evidence-export files shared their base64, render and download code and one exported a button nothing imported; one hook now serves the hub and the case page. The containment drawer reads each action's ParameterName from the catalog instead of a hand-kept map and builds its picker options once. The remediation history uses CippDataTable. The window-start computation lives in one helper, the unused finding flag/error keys and eventCount are gone, CippRemediationCard (orphaned by the deleted page) is removed, and the user's Compromise Remediation tab points straight at the case workspace instead of relying on the 404 redirect.
… action's original four steps An explicit MobileDeviceIds pick that the run's inventory did not know (no run, or an API caller) was turned into a row whose Guid was the DeviceID, so Remove-MobileDevice got the wrong identity. Picks are now resolved against the run first and, when any is unknown or there is no run, against the mailbox's partnerships (Get-MobileDevice, as the existing removal helper does); an id that still matches nothing is reported as an error row instead of guessed. The audit-log alert action Execute a BEC Remediate ran four steps before containment became selectable (password, sign-in, sessions, inbox rules). Routing it through the dispatcher had silently widened that to the drawer's six defaults for every rule with no stored selection, adding MFA removal and OneDrive sharing to automated remediation. The action now passes its original four explicitly when the rule has none; a rule that wants more picks them.
Generated by the dev module watcher after the endpoint trim: ListBECEvidence is gone and ListBECRemediationActions returns the array directly.
Every run has been the full investigation since the case workflow landed; the Scope value only survived as a label for pre-rewrite rows. The run, the queue item, the history row and the evidence package no longer carry it (the poll and list responses drop it in the next commit, with the evidence fields), the status card's "Quick check (older run)" chip and the containment drawer's "run the full analysis" hint are gone, and the PDF report no longer gates its deeper sections on it. The report sample data and the reports-page docs follow.
The package is what the analyst hands on: the results, a CSV per finding set, the score, the containment history, the case logbook and both browser-rendered PDFs, built fresh and streamed back in the ZIP. The SHA-256 manifest, the ZIP hash and the per-run export ledger (EvidenceExports, EvidenceSha256, EvidenceCreatedAt, EvidenceBytes) served a verification story nothing in the UI completed, so they are removed: the run row is no longer written on export, the reports list and the poll drop the evidence fields and the HasEvidence column, the export button tooltip no longer shows a hash, and the docs describe the package as it is.
Eight of the eleven hand-written finding renderers in the objective groups were the same shape: a summary line, a table, titled tables below it and prose for an empty result. Those are now fields on the finding definition in bec-objectives (rows, sections, summary, alert, empty) and one generic renderer draws them; only Identity Protection (dismiss dialog), mailbox state (property list) and received mail (spread button and row action) keep a renderer of their own. Two things the old per-user page showed come back through the same data: the sent-mail analysis lists its repeated subjects and send bursts again, and the application findings show the catalog source (CIPP's own MaliciousApps.json, the Huntress feed, or both), categories and description for every match, not just the name.
…ulk users The orchestrator reworded any Intune "An error has occurred" response to say the tenant may not have Intune provisioned, and the error classifier matched exactly that phrase, so a transient 5xx from the Intune service was recorded as a skipped check rather than a failed one. The rewording now says it is transient and asks for a rerun; the classifier's own not-provisioned patterns still match the real DeviceFE messages. The bulk check returned "User not found" rows in a Cases array nothing displays. The result text now names how many selected users could not be resolved and which ids were skipped.
… log CodeQL flagged the evidence hook's catch: the case id flowed from the row into the thrown message and from there into console.error. The thrown message no longer carries the id (the operator clicked that case), and the log line carries only the error message with line breaks stripped, rather than the raw error object.
…ng column A run that has never been contained has no Containment column, and @($null).Count is 1, so every fresh run listed one containment run on the BEC hub. Count only the stored entries.
The evidence export, run delete, reports list, phishing-spread and containment-catalog endpoints, the directory-audit, registered-device, non-interactive sign-in, mail-activity and risk-state collectors, and the OAuth-grant, delegation and targeted-CA-policy mutators had no tests of their own; the containment dispatcher only stubbed them. Each now has a focused suite in the existing stub-and-mock style: the request contract, the projection, the boundary and cap logic, the guards and the error paths. Writing them found the fresh-run ContainmentRuns miscount fixed in the previous commit.
The recently-added-users check already listed guests (the user query has no type filter) but did not say which rows were guests, and the flagged directory activities did not include inviting or creating a user. New users now carry userType, and "Invite external user" and "Add user" are flagged - scoped, like every directory audit in the run, to events where the investigated user was the actor or the target.
…ions on the account Customer-tenant audit records name a partner (GDAP) identity in the two shapes the audit-log pipeline already maps back to partner users: user_<id>@<tenant>.onmicrosoft.com in Entra records and <customer>.onmicrosoft.com\tenant: <partner tenant id>, object: <id> in Exchange records. The partner-user cache block in Test-CIPPAuditLogRules is now the shared Get-CIPPPartnerUserLookup (same memo, same table row), and Resolve-CIPPAuditActor classifies an actor as the tenant own user, a partner identity (this partner or another, by tenant id), CIPP own service principal (by app id), an application or a system account, with the partner user resolved to its name. The BEC run stamps ActorKind and ActorResolved on every actor-bearing row - inbox-rule, safelist, sharing, permission and transport-rule changes, directory audits (which now carry the initiating id) and mailbox activity. The case page shows the kind beside each actor, folds who changed a rule into the rule row, and adds a "Partner and CIPP actions on this account" finding that gathers every such change from all sources, newest first, so routine MSP work reads as such and anything a partner identity did that the investigation did not expect stands out. The score is unchanged.
…rill-in The "By user" toggle only reduced the flat list to one row per user and dropped a couple of columns, so with one run per user it looked like the same table minus columns. Each summary row now carries its full run history and a "View runs" action that opens every run for that user in a drawer, with the same per-run actions as the flat view (open, download evidence, delete a specific run) - so grouping no longer hides the individual runs. Delete refreshes the grouped list too. Summary columns now lead with the run count, and the triage filters apply to both views.
The by-user view fed the table collapsed rows via `data` but shared the flat view's queryKey. CippDataTable's internal query read that key's cached per-run list (an infinite-query {pages} object) and its success effect overwrote the grouped rows, so the list never actually collapsed - it only looked like the flat table with a couple of columns dropped. The grouped source fetch shared the key too, so read as a plain array it saw the {pages} object and returned nothing. Give the grouped table and its source fetch their own keys, and invalidate both keys on delete and on queuing an investigation.
Zacgoose
force-pushed
the
preview/bec-case-workflow
branch
from
September 23, 2026 07:27
380aa7e to
3776d90
Compare
Zacgoose
marked this pull request as ready for review
September 23, 2026 07:29
…ops on load errors - New CIPP settings card + page (/cipp/settings/bec-remediation) to switch each containment action on or off by default, stored in the Settings table and saved through ExecBECRemediationDefaults (CIPP.AppSettings.ReadWrite). - Get-CIPPBecContainmentActions applies the saved defaults, so the containment drawer (ListBECRemediationActions) and remediation without a selection both use them. ListBECRemediationActions now needs Identity.User.ReadWrite, the same role as ExecBECRemediate. - Built-in defaults: block legacy mailbox protocols is on and now includes SMTP AUTH; disable OneDrive sharing is off. - The case page treats a failed poll (e.g. a deleted or corrupted case answering 500) as finished and shows its error instead of loading forever.
ConvertFrom-Json unrolls a JSON array, so a case with exactly one
remediation came back with Containment as a bare object and the case
page's remediation history crashed spreading it ("is not iterable").
Wrap the parse in @() where the run row is read.
- ExecBECRemediate takes Async: after validating the selection and the typed confirmation it queues Invoke-CIPPBecContainment as a run-now scheduled task (Start-CIPPBecContainmentJob) and returns a DeploymentId, the same model as user offboarding. Without Async it still runs inline. - Invoke-CIPPBecContainment takes -DeploymentId and reports one progress step per action, and loads the case results itself when given only a CaseId. The scheduled task stores the redacted rows; only the progress step message carries a new password so the operator can copy it. - The containment drawer always runs in the background and shows the per-action progress (read through ListOffboardingProgress); when the job finishes the case refreshes so the remediation history shows the run. - CippApiResults jobProgress gains an onComplete callback.
- "Remediation taken" and "Attack timeline" on the case page are accordions like the objective groups (open by default); the history header shows the run count. - The timeline gets a "Full screen" button that opens the selected view (timeline or correlation graph) in a full-screen dialog with its own view toggle. The correlation graph takes a fill prop so it uses the whole dialog instead of its fixed 560px scroll box; toolbars wrap on phones.
The attack timeline and correlation graph are built client-side from the
case payload and grouped events by the raw source address. Cases
collected before the backend normalised audit-log addresses still store
them with the per-connection port ("203.0.113.10:51234",
"[2001:db8::1]:443"), so one host split into a graph hub per connection.
Every event address now goes through hostIp, the same normalisation as
ConvertTo-CIPPBecHostAddress.
…ck list - ExecAddTrustedIP accepts Trusted, Blocked or NotTrusted and IPv4/IPv6 addresses or CIDR ranges (several per call), validates every entry, and stores a range under a key-safe RowKey with the range and an optional note alongside. ListIPWhitelist returns the range for every row. - Get-CIPPIPAllowBlockList and Resolve-CIPPIPAllowBlockList read the list for a tenant: the most specific range decides an address, and a tenant entry overrides an AllTenants entry for the same range. - The Geo IP page can trust, block or neutralise addresses and ranges and shows the range and note of each entry.
A new run phase decides, per address in the window's sign-ins and audited actions, who it belongs to, with a score and the reasons: - Investigator overrides and the CIPP IP allow/block list decide outright. - Microsoft addresses the user never signed in from, and addresses seen only on CIPP or partner actions, are services. - Heuristics raise the score for an address behind a flagged action, a hosting or proxy network, a foreign location, one new to the user, a risky sign-in or a scripted client, or other accounts appearing on it only in the window; they lower it for the user's regular addresses, networks and locations before the window, a compliant device, colleagues on it before the window, a trusted named location, and failed-only sign-ins. A shared Entra or mailbox session with an attacker address lifts the rest of the session. - Evidence: a 30-day interactive and non-interactive sign-in baseline, trusted named locations and the Exchange IP lists as hints, geo, and a tenant-wide lookup of the other accounts on each unsettled address. - Verdicts are stored on the case (IPVerdicts, IPBaseline, IPGuidance, IPPeers), stamped onto every sign-in and change row, and add an AttackerIPs score signal. Sign-in rows keep ASN, risk, user agent, device, session and token ids. Weights live in BecHeuristics.json.
A $batch item whose first page came back 429 (or 503/504) was handed to the caller as a failure, while the same condition on a continuation page was already retried. Directory audits in the BEC investigation, among others, then showed "This request is throttled" as a failed check. Throttled and briefly unavailable items are now sent again, alone, up to twice, waiting the longest Retry-After they asked for (capped at 30 s). An item that stays throttled is returned as it came back; other errors are never retried.
A new run phase details what the attacker-side addresses (Compromised, LikelyAttacker, Suspicious, Unknown) did, while the user's own activity stays as counts: - Mail, from the mailbox records the activity search already read: each message opened (internet message id and folder, named from the run's traces or a batched Get-MessageTraceV2 -MessageId lookup up to 90 days back), each folder synced by a desktop client, each item deleted, moved or sent, attachments read, folder-permission, calendar-delegation, mailbox-setting and mailbox-search events. - Every record is tied to an address even without one: its own client address, else the sign-in behind its token (AppAccessContext UniqueTokenId), its Entra session (AADSessionId) or its mailbox session; the worst verdict wins and a Microsoft front-end address never hides the actor. Records tied to nothing are counted, not guessed. - Files: one OneDrive/SharePoint search filtered to the attacker-side addresses (Search-CIPPBecAuditLog gains -IPAddresses and -FreeText). - Link usage: who opened the links created in the window, by item. - Microsoft Forms: every action by the account with its verdict; for forms built or shared from an attacker address, responses, anonymous responses, views and Microsoft's phishing flag. - Delegated access: the mailboxes the account reaches, from the CIPP permission cache (mailbox and calendar reverse lookup), grants in the window and delegate activity, with what the attacker did in each. - MailActivity reads the access type from OperationProperties (it was always empty on real records), lists mailbox sessions per row and searches attachment access, moves, folder permissions, calendar delegation, mailbox search and Set-Mailbox as well. - New score signals: AttackerMailAccess, AttackerFileAccess, AttackerForms and DelegatedMailboxAttackerAccess, counted only from addresses judged the attacker's.
ExecBECIPReview validates the investigator's overrides (an address or CIDR range marked Safe or Compromised, with a note; Auto drops one) and the accounts to correlate, then queues a background re-run with live progress, like containment. Invoke-CIPPBecIPReview keeps the case window and the stored sign-in baseline and peers, correlates the chosen accounts' sign-ins with the case's addresses (Get-CIPPBecCorrelatedUserPeers), re-reads the address lists so a new CIPP IP list entry counts, re-reads mailbox activity, re-judges the verdicts, re-collects attacker activity and delegated mailboxes, re-stamps the rows, recomputes the score and appends to IPReviewHistory. Sections that do not depend on the verdicts keep their data and completeness markers.
…anged reviews refused - The first run correlates a random sample of recently active member accounts (Get-CIPPBecColleagueSample, baseline.colleagueSample) with the case's addresses, so shared offices and VPNs are recognised without the investigator picking anyone. A colleague found by both lookups is counted once. - An address whose sign-ins are all the CIPP application's own (its service account refreshing tokens) is a Service, not an unknown or an attacker; sign-in rows keep their AppId. - ExecBECIPReview refuses a re-run whose overrides match the case's current ones and that correlates no accounts.
…ise on this account - A new first group, "Attacker IPs & activity": the IP verdicts with their reasons, the mail and files the attacker touched, sharing links opened, Microsoft Forms, and the other mailboxes the account reaches, each with its flag, coverage markers and score-signal routing. Sign-in and mailbox-activity tables show the verdict of each address. - "Review IPs" on the case: one card per address with its verdict, score and each reason with its signed weight, an Auto/Safe/Compromised choice and a note. The re-run is only enabled once a verdict differs from the case, keeps overrides for ranges not in the list, and can remember the choices in the tenant's CIPP IP list. Progress is shown live and the case refreshes when it finishes. - The timeline folds attacker mail and file activity per hour and address, lists flagged Forms actions and sign-ins from attacker addresses at home, and the graph marks attacker sources. - The start of compromise is the first sign-in or action from an attacker address, then a successful foreign sign-in, then a high-severity foothold on the account. Tenant-wide events (users created, passwords changed elsewhere) are never the start, and with no evidence there is no marker instead of the earliest event.
Sign-ins through Microsoft's Partner Customer Delegated Administration app (GDAP access) are partner machinery, not the user or an attacker, but an address that only had those showed as Unknown. The service-app list is now ipVerdict.serviceAppIds in BecHeuristics.json (that app by default) plus CIPP's own application id.
Auto is the verdict a case already ran with, so a review where every address is still Auto would recompute the same case. The drawer keeps "Re-run with these verdicts" disabled until an address is set to Safe or Compromised (and the set differs from the one already applied), and says why under the button; ExecBECIPReview refuses a request with no overrides and no accounts to correlate.
…e attacker
Starting an investigation (single or bulk) records the requester's
address - the first x-forwarded-for hop without port or brackets - on the
case and the run item; a review adds the reviewer's. The verdicts class
those addresses as a service ("the technician's own address"), after
investigator overrides and the CIPP IP list, so a technician testing as
the user or running containment never lands on the attacker side. The
addresses are stored on the case as IPTechnicians.
…f a fixed verdict The address a case was run or reviewed from is most likely the partner's, but it can be shared or wrong, so it now weighs towards trusted (ipVerdict.weights.technicianAddress, -4) and is still judged on the rest of the evidence rather than being classed as a service outright.
For every address judged Compromised or LikelyAttacker, list the other accounts it reached: the tenant's sign-ins from it (per account successful and failed, reusing the IP analysis lookup, which now keeps per-account detail) and the tenant-wide audit log for it (per account operations). Accounts seen only in the audit log are resolved to object ids so the new Investigate row action can queue a case for each (bulk too). The IP review re-run recomputes it, and a reached account adds the OtherAccountsReached score signal. Audit CreationTime is read as UTC rather than host-local time.
Both variants: two plain statements in What We Found (the attacker's addresses and what was done from them; the other accounts, mailboxes and forms reached), three rows in Findings at a Glance, and tailored actions to secure the other accounts, block the addresses, remove the forms and check the delegated mailboxes. Only Compromised/LikelyAttacker addresses count toward a statement. Full report: an Attacker Addresses & Activity page with the attacker and suspicious addresses and the reasons behind each verdict, activity totals with the first items of each kind, the other accounts reached, the mailboxes reached through delegation and the reach of the forms, each capped with the rest left to the evidence export. The preview sample carries the new fields.
No API removes or disables a single Microsoft Form, so a flagged Forms finding now carries remediation guidance with links: confirm phishing and delete the form from its Microsoft Defender alert, or delete it (or stop responses) in Microsoft Forms as the account after the password reset. The report's form action says the same.
The attacker group is not a timeline objective, so the objective label and colour maps have no entry for it and its bar rendered unlabelled. Fall back to the group's title and a red bar.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the one-shot BEC check with a case workflow. Each investigation is a stored case you can come back to, contain and report on. On top of that it works out which IP addresses belong to the attacker and follows them, both through this account and across the tenant.
Case workflow
Containment
Attacker IP analysis
One Entra or mailbox session that moves between addresses is treated as one actor. Weights and thresholds live in
BecHeuristics.json.What the attacker did
Audit records without an IP are tied back to one through the sign-in token, the Entra session or the mailbox session.
Report (client-side)
Fixes along the way
New-GraphBulkRequestretries throttled (429/503/504) first pages.Testing