Skip to content

feat(bec): BEC case workflow with attacker IP analysis, blast radius and background containment - #490

Merged
KelvinTegelaar merged 47 commits into
devfrom
preview/bec-case-workflow
Sep 23, 2026
Merged

KelvinTegelaar merged 47 commits into
devfrom
preview/bec-case-workflow

Conversation

@Zacgoose

@Zacgoose Zacgoose commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Stored cases. Every run is kept (BecReports/BecResults tables) with a case ID. From the BEC page you can start one user (you land in the live case) or queue several. The page groups runs by user, and each user has a run history.
  • Collectors. A full investigation covers:
    • interactive and non-interactive sign-ins, and risk state;
    • MFA methods, registered and Intune devices;
    • inbox rules and their audit trail; delegations, safelists, add-ins, forwarding and auto-reply; mailbox and transport rule changes;
    • sent mail through message trace, with mass-mail and burst detection; received phishing and Defender detections;
    • sharing links; mailbox activity; app consents matched against the rogue-app catalogs; directory audits.
  • Check status. Licence gates are checked up front. A check that can't run is shown as "not checked", never as a clean pass. Logs are read to the end of the window: a busy slice is split in time rather than cut off at a row count.
  • Case page. Findings are grouped by attacker objective. The page also has a triage header with the score and its signals, a timeline and correlation graph (full-screen on any device), and an evidence ZIP export.
  • Actor labels. Every audited actor is classed as user, partner (GDAP), CIPP or other, so routine MSP work reads as such.

Containment

  • Always a background job. Remediation runs as a scheduled task, with live progress in the drawer as it would for offboarding. The containment history is stored on the case.
  • Instance-wide defaults. A new page under CIPP → Settings → BEC Remediation sets which actions start selected. New defaults: block legacy mailbox protocols (now including SMTP AUTH) is on; disable OneDrive sharing is off.
  • Actions. Includes a targeted Conditional Access policy that removes itself after a set time, sharing-link removal, device disable/delete, transport rule and add-in disabling, and tenant-wide sender blocking.
  • Resilience. A case whose data is missing or corrupt shows the error instead of re-polling a 500 forever. A containment history with a single entry no longer crashes the history view.

Attacker IP analysis

  • Verdicts. Every address on the case is judged Compromised, Likely attacker, Suspicious, Unknown, Likely user, Safe or Service. The inputs are:
    • a 30-day sign-in baseline (interactive and non-interactive) covering addresses, ASNs and locations;
    • the CIPP IP allow/block list; the tenant's trusted named locations and Exchange connection filter;
    • geo, hosting and proxy data;
    • other accounts on the same address, including a random sample of active colleagues;
    • risky sign-ins and scripted user agents.
      One Entra or mailbox session that moves between addresses is treated as one actor. Weights and thresholds live in BecHeuristics.json.
  • Services and technicians. CIPP's own sign-ins, partner delegated administration (GDAP) sign-ins, and Microsoft service addresses with no sign-ins are classed as Service. The address of the technician who ran or reviewed the case (first X-Forwarded-For hop) starts with a trusted weighting but is still judged.
  • CIPP IP list. It gains a Blocked state and CIDR ranges. Entries apply to one tenant or to all tenants, and the tenant entry wins when both match. The Geo IP page can trust, block or remove an address.
  • IP review. A "Review IPs" drawer lets the investigator override verdicts per case, optionally saving them to the tenant's CIPP IP list. It then re-runs everything that depends on the verdicts as a background job. A re-run is only allowed when at least one address is set to something other than Auto.

What the attacker did

  • Item-level activity from attacker addresses.
    • Mail opened, synced, sent, deleted or moved, with subjects resolved through message trace.
    • Files downloaded, opened or shared, and sharing links used.
    • Microsoft Forms created or shared, with how many responses and views each drew.
      Audit records without an IP are tied back to one through the sign-in token, the Entra session or the mailbox session.
  • Delegated mailboxes. Other mailboxes this account can reach, found by reverse lookup in the permission cache and from grants in the window, with what the attacker did in each.
  • Blast radius. For Compromised or Likely attacker addresses, a tenant-wide search of sign-ins and the audit log lists every other account reached. The Investigate row action (bulk too) queues a case for each one.
  • Forms remediation. A flagged Forms finding explains how to remove the form, with links. There is no API to remove a single form: an admin confirms phishing and deletes it from the Defender alert, or the form is deleted in Forms as the account after the password reset.
  • Start of compromise. The timeline marks the first evidence of someone else in this account, never a tenant-wide event.
  • New score signals: attacker IPs, attacker mail, file and forms access, delegated mailbox access, and other accounts reached.

Report (client-side)

  • Both variants. The PDF gains the attacker's addresses and what was done from them, the reach beyond the account, and tailored actions (secure the other accounts, block the addresses, remove the forms). The C-suite summary gets two condensed statements.
  • Full report. Adds an Attacker Addresses & Activity page. Every list is capped, with the rest in the evidence export.

Fixes along the way

  • New-GraphBulkRequest retries throttled (429/503/504) first pages.
  • Timeline sources are grouped by host, ignoring the client port.
  • Audit timestamps are read as UTC.

Testing

  • Pester: ~300 BEC and IP tests covering collectors, verdicts, IP review, blast radius, containment, endpoints and scoring.
  • Vitest: objectives, timeline, IP review drawer, containment drawer and report rendering.
  • Checked live on a test tenant: IP verdicts, IP review re-run, the technician address, GDAP sign-ins treated as a service, and the blast radius (sign-ins and tenant-wide audit log).

@Zacgoose
Zacgoose force-pushed the preview/bec-case-workflow branch 7 times, most recently from 665a892 to 0539fb0 Compare September 6, 2026 10:32
@Zacgoose
Zacgoose marked this pull request as ready for review September 7, 2026 06:29
@Zacgoose
Zacgoose marked this pull request as draft September 7, 2026 11:10
@Zacgoose
Zacgoose force-pushed the preview/bec-case-workflow branch 3 times, most recently from 25a457f to ba5dd13 Compare September 9, 2026 09:12
@Zacgoose
Zacgoose force-pushed the preview/bec-case-workflow branch from ba5dd13 to 380aa7e Compare September 14, 2026 16:05
…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
Zacgoose force-pushed the preview/bec-case-workflow branch from 380aa7e to 3776d90 Compare September 23, 2026 07:27
@Zacgoose
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.
@Zacgoose Zacgoose changed the title feat(bec): add full BEC case workflow feat(bec): BEC case workflow with attacker IP analysis, blast radius and background containment Sep 23, 2026
@KelvinTegelaar
KelvinTegelaar merged commit fd9b7ea into dev Sep 23, 2026
25 checks passed
@Zacgoose
Zacgoose deleted the preview/bec-case-workflow branch October 9, 2026 14:25
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.

3 participants