Stolen vendor app credentials for ConnectWise (ex-SkyKick) multi-tenant M365 applications were used from October 15 to mint Graph tokens across ~2,250 tenants (Microsoft; Huntress counted 300+ successful of ~1,000 observed). Microsoft attributed the campaign to Storm-0940 on October 21, operating the ARTEX agentic-AI framework.
Verdict: hunt now if the ConnectWise app IDs ever touched your tenant — including former customers. This is credential abuse of a vendor's multi-tenant apps, not a patchable CVE: there is nothing to scan for, only logs to review. Sophos reports the token sweep reached tenants with no active backup subscription, so past use counts. ConnectWise disabled the seven app identities, but previously issued tokens may remain usable until they expire, and attacker persistence (added credentials, role grants) survives the vendor-side fix.
Password spraying (151,000+ failures, Oct 10–14) preceded abuse of stolen ConnectWise application credentials from Oct 15: service-principal sign-ins from attacker infrastructure minting Graph tokens in customer tenants. Follow-on activity where sign-ins succeeded: directory reconnaissance, new client secrets planted on other applications (persistence beyond the ConnectWise apps), and privileged role changes including a service principal self-assigning Global Administrator. Conditional Access policies scoped to users often do not evaluate multi-tenant service-principal sign-ins — check yours rather than assuming coverage.
| Application | appId |
|---|---|
| ConnectWise Cloud Backup | 506e08f9-6e9a-4da4-942b-a099a03644ae |
| ConnectWise Cloud Management | 2399d300-6fca-4e6a-a686-399fefe75b83 |
| Cloud Management — MS Teams | 24205314-2b89-4e07-a2a4-579e898a5640 |
| Cloud Management — Exchange Online | b78f6a77-d4e5-46fb-87e5-d1648bd02519 |
| Cloud Management — MS Graph | 0de14c77-b9b0-4137-9360-34878a929827 |
| Cloud Management — Partner Center | 412dcf96-176d-4b3a-a1b0-4981ad66971e |
| Cloud Management — SharePoint Online | b2b55f5b-7758-4719-8d8f-88ae172e6bdb |
ConnectWise notes checklist completion does not guarantee a tenant was unaffected — absence of the apps is the only clean verdict.
Python-urllib/3.12 (enumeration and escalation tooling), python-requests/2.31 and 2.34.2 (targeted sweeps; the Oct 10–14 spray), curl/7.88.1 (Debian 12 python:3.12 container), azure-toolkit/1.0 (listed by Microsoft, not seen by Huntress) — expected vendor traffic is kiota-dotnet from Azure private IPv6. One Oct 13 browser user agent was missing its (KHTML, like Gecko) segment — a hand-set stringpython-requests/2.31 only)00000000-0000-0000-0000-000000000000) is Microsoft-documented service-principal-less authentication: "the service principal ID of an application making a service principal-less authentication is shown as 00000000-0000-0000-0000-000000000000 in the sign-in logs of the resource tenant" (Entra docs). A multitenant app obtains an app-only token from a tenant where it has no service principal object — no consent, no object ID claim, and in Microsoft's words "in most cases … harmless", except "where the target API has implemented improper authorization checks". That is the mechanism behind Chorus Cyber's observation: the campaign's token requests appear in tenants that never consented Cloud Backup; Chorus excludes those rows as noise. Read it the other way too: an all-zeros sign-in followed by successful Graph activity for the same appId is exactly the "improper authorization checks" case Microsoft warns about, and is a finding (inferred test below). Unresolved: Microsoft's retirement blocks SP-less auth for all non-Microsoft multitenant apps after March 31, 2026 (apps seen Feb 11–Mar 11 2025 were allowed until then; most others frozen April 2025; the Entra blog's timeline says the March date blocks "all client apps without a service principal ... including the ones previously allowed to continue working", while Microsoft's monitoring FAQ, updated since, still says tokens are issued "except for a few Microsoft and non-Microsoft applications") — all-zeros successful sign-ins for a third-party app in October 2026 should therefore be rare. Either enforcement is incomplete, the app is one of the FAQ's exceptions, or the logged rows are blocked attempts (check resultType). Both of Microsoft's freezes were scoped per resource, so an exception is a client-and-resource pair: keep the resource column when you inventory these. A "Success" row is issuance, not access: Inforcer (Oct 17) saw Graph issue the token with no permissions while Exchange Online and SharePoint Online rejected it with "missing service principal", and the Office 365 Management APIs (in Huntress's six-resource probe list) refuse any token lacking ActivityFeed.Read with error AF10001; none of those rejections write a tenant-side log. The companion note has the claim-by-claim table, sourced from Microsoft's token sample, Entra blog, claims reference and Q&A, with Inforcer and Huntress for the per-resource rows. Microsoft first-party apps still authenticate SP-less by design and show in user (mostly non-interactive) sign-in logs — expected, not this campaign; those records carry the user's device IP, so non-Microsoft ASNs there are normal. The question that matters is the service-principal (app-only) tab: all-zeros sign-ins whose appId resolves to a non-Microsoft publisher are grandfathered SP-less apps — a short, finite list worth a watch list| Source | Status |
|---|---|
| ConnectWise | Acted + guide published — seven app identities disabled 07:00 UTC Oct 17 (Trust Center); Cloud Backup / SaaS Security / Cloud Management suspended. The Oct 18 technical investigation & remediation guide is the vendor playbook: per-tenant checklist covering SP sign-in review (window Oct 13 → now, extend to Oct 1), Entra audit, Conditional Access review, federated credentials on locally registered apps, and Exchange/Purview audit — run it in every tenant including dormant connections; it covers resold/managed tenants too (reviewing your own tenant does not review the customer's). Live help exists: technical webinars every two hours since Oct 20 18:00 UTC (walkthrough of Entra sign-in/audit logs, app registrations, credentials, permissions — register and pick a session; share the invite with customer admins), plus a video walkthrough of guide steps. The Trust Center advisory updates several times daily (20 updates Oct 16–23). Caveats: completing the checklist does not establish a tenant was unaffected; previously issued tokens may remain valid until expiry; escalation route SecurityPartnerSupport@ConnectWise.com (tenant/app IDs, UTC timestamps, correlation IDs — no secrets) |
| Microsoft | Published — Defender XDR alerts ("Application self-assigned Global Administrator role", "Unusual Microsoft Graph activity associated with Compromised ConnectWise Application") plus KQL for OAuthAppInfo, EntraIdSpnSignInEvents, GraphAPIAuditEvents and Sentinel SigninLogs in the Oct 21 Storm-0940 post (tier A) |
| Microsoft — Entra platform docs | Published (platform semantics, tier A) — the all-zeros ServicePrincipalId is documented SP-less authentication; admin-center procedure: Sign-in logs → Service principal sign-ins tab → filter Service principal ID = 00000000-0000-0000-0000-000000000000 → read the Application ID per entry. Documented blocking control: "create a service principal for the app, and then disable the service principal. Disabling the app's service principal will block all future sign-in and authentication attempts by that app in your tenant" — applicable per appId in tenants that never consented Cloud Backup. The same page says this brings "the ability to target these apps individually with Conditional Access policies", but Microsoft's workload-identity Conditional Access page says policy "can be applied to single tenant service principals that are registered in your tenant" and that "multitenant apps, are not covered", so for a vendor app the disabled service principal is the control, not Conditional Access. Retirement timeline per the Entra blog: most resource apps frozen April 2025; traffic seen Feb 11–Mar 11 2025 allowed until March 31, 2026; low-volume clients notified with a month to register. No published list of exempt third-party apps; first-party Microsoft apps are exempt (Microsoft's verification page: f8cdef31-a31e-4b4a-93e4-5f571e91255a = "the Microsoft Service's Microsoft Entra tenant ID", 72f988bf-86f1-41af-91ab-2d7cd011db47 = the tenant of "Microsoft tenant-owned applications", listed as "some, but not all"; the same page notes first-party apps "don't always result in a service principal that's created in your tenant"). Publisher field, cross-checked Oct 27: the Azure Monitor table AADServicePrincipalSignInLogs carries AppOwnerTenantId ("the tenant identifier of the owner of the application"), and the Graph signIn resource exposes appOwnerTenantId on beta only (v1.0 does not return it); the MicrosoftGraphActivityLogs table has no owner or publisher column. Naming an owner tenant: findTenantInformationByTenantId is v1.0, needs only CrossTenantInformation.ReadBasic.All and no directory role, and returns displayName and defaultDomainName. Caveat: a volunteer-moderator Q&A answer (Oct 2025) notes an app can appear under Enterprise Applications yet still log zeros when not properly provisioned/consented — presence in the app list is not proof of a service principal |
| Huntress | Published — Entra review procedure: SP sign-ins by appId vs non-Azure source IPs, audit events (Add member to role / Update application / Add service principal credentials), secrets added since Oct 13, CA exclusions; disable (do not delete) the apps. Oct 26 follow-up ("scripts, people, and AI agents", tier A, from Entra sign-in + directory audit logs; Graph activity logs were not available to them): persistence observed as add → remove → add churn of an application secret and of the Global Administrator role (03:42–03:57 UTC Oct 15), a certificate credential upload that failed on a thumbprint mismatch and was retried to success 55 s later, the Teams application identity self-assigning Global Administrator via Graph (T1098.003), a refused GA removal answered by granting a different role to a sibling service principal, and service-principal deletion afterwards (Teams SP deleted 03:57). No queries printed; no appIds listed. Recommendation: enable Microsoft Graph activity logs. Confidence that an AI agent was in the loop: low–medium on their telemetry alone, medium–high with Microsoft's attribution |
| Chorus Cyber | Published (MSSP advisory, Oct 19, tier B) — sign-in interpretation: a successful sign-in for the Cloud Backup app from the attacker IP can appear even where the product was never used; entries carry an all-zeros ServicePrincipalId and their queries exclude them, because an unconsented app has no permissions in the tenant. Artefacts to review beyond sign-ins: new secrets or certificates on enterprise applications and app registrations, directory roles held by ConnectWise/SkyKick apps, Conditional Access exclusions for those apps. Activity from Oct 13; credential use from Oct 15. Caveats: no queries printed ("our detection rule identifies this activity"); the IOC table is an image, not machine-readable; the seven appIds are referenced but not listed |
| CISA | No advisory found as of Oct 23; no KEV entry (no CVE exists) |
| VM scanners / EDR | Not applicable — nothing to scan; coverage is Entra/Graph log review and the Microsoft/Huntress content above |
The published queries above come first — Microsoft's KQL and the ConnectWise guide checklist supersede everything here. This is the fallback translation for environments without Defender XDR / Sentinel tables, over exported Entra logs in any SIEM or even a spreadsheet:
Service-principal sign-ins — the core question, tool-agnostic:
# exported Entra SP sign-in logs, Oct 1 → now appId IN (the seven appIds above) AND NOT (source = Azure private IPv6 AND user_agent = kiota-dotnet) → review every remaining row; Python/curl UAs from VPS space is the campaign
Persistence audit — a service principal doing admin things is the tell:
# Entra audit log, Oct 13 → now
operation IN ("Add service principal credentials", "Add member to role",
"Update application", "Consent to application")
AND initiated_by is a service principal (not a user)
→ every hit gets a human review; self-role-assignment = incident
# Splunk (Azure Monitor diagnostic export shape: category / properties.*; tune field
# names to your add-on). Includes the REMOVE and DELETE operations: Huntress's Oct 26
# telemetry shows the persistence as churn and cleanup, not just additions.
index=* category=AuditLogs earliest=-30d@d
operationName IN ("Add service principal credentials", "Remove service principal credentials",
"Update application – Certificates and secrets management",
"Add member to role", "Remove member from role",
"Add app role assignment to service principal",
"Update application", "Consent to application",
"Remove service principal", "Delete application")
properties.initiatedBy.app.servicePrincipalId=*
| rename properties.initiatedBy.app.displayName as actor_app,
properties.initiatedBy.app.servicePrincipalId as actor_sp,
properties.targetResources{}.displayName as target
| stats count values(target) as targets min(_time) as first max(_time) as last
by actor_app actor_sp operationName
# churn: the same target gets an add AND a remove of credentials or roles inside 60 min
index=* category=AuditLogs earliest=-30d@d
operationName IN ("Add service principal credentials", "Remove service principal credentials",
"Add member to role", "Remove member from role")
| rename properties.targetResources{}.displayName as target, properties.initiatedBy.app.displayName as actor_app
| eval verb=if(match(operationName, "^Add"), "add", "remove")
| transaction target maxspan=60m
| where mvcount(mvdedup(verb))=2
| table _time duration target actor_app operationName
→ any churn pair is a hands-on operator (or an agent retrying); a failed cert upload followed by
a successful one within a minute is the same tell
# sibling-principal grant + cleanup: role granted to one principal, then another principal deleted
index=* category=AuditLogs earliest=-30d@d
operationName IN ("Add member to role", "Remove service principal", "Delete application")
| rename properties.initiatedBy.app.displayName as actor_app
| transaction actor_app maxspan=30m
| where searchmatch("Add member to role") AND (searchmatch("Remove service principal") OR searchmatch("Delete application"))
| table _time duration actor_app operationName
// Elastic ES|QL (Azure Logs integration shape: azure.auditlogs.*; tune field names to your
// integration). Includes the REMOVE and DELETE operations: Huntress's Oct 26 telemetry shows
// the persistence as churn and cleanup, not just additions.
FROM logs-*
| WHERE @timestamp > NOW() - 30 days AND event.dataset == "azure.auditlogs"
AND azure.auditlogs.operation_name IN ("Add service principal credentials", "Remove service principal credentials",
"Update application – Certificates and secrets management",
"Add member to role", "Remove member from role",
"Add app role assignment to service principal",
"Update application", "Consent to application",
"Remove service principal", "Delete application")
AND azure.auditlogs.properties.initiated_by.app.servicePrincipalId IS NOT NULL
| EVAL actor_app = azure.auditlogs.properties.initiated_by.app.displayName, actor_sp = azure.auditlogs.properties.initiated_by.app.servicePrincipalId,
target = azure.auditlogs.properties.target_resources.0.display_name
| STATS count = COUNT(*), targets = VALUES(target), first = MIN(@timestamp), last = MAX(@timestamp)
BY actor_app, actor_sp, azure.auditlogs.operation_name
// churn: the same target gets an add AND a remove of credentials or roles inside the same hour
// (ES|QL has no transaction; one-hour buckets stand in for the 60-minute span)
FROM logs-*
| WHERE @timestamp > NOW() - 30 days AND event.dataset == "azure.auditlogs"
AND azure.auditlogs.operation_name IN ("Add service principal credentials", "Remove service principal credentials",
"Add member to role", "Remove member from role")
| EVAL target = azure.auditlogs.properties.target_resources.0.display_name, actor_app = azure.auditlogs.properties.initiated_by.app.displayName,
verb = CASE(STARTS_WITH(azure.auditlogs.operation_name, "Add"), "add", "remove"), window = DATE_TRUNC(1 hour, @timestamp)
| STATS verbs = COUNT_DISTINCT(verb), first = MIN(@timestamp), last = MAX(@timestamp), ops = VALUES(azure.auditlogs.operation_name), actors = VALUES(actor_app) BY target, window
| WHERE verbs == 2
| KEEP first, last, target, actors, ops
// any churn pair is a hands-on operator (or an agent retrying); a failed cert upload followed by
// a successful one within a minute is the same tell
// sibling-principal grant + cleanup: role granted to one principal, then another principal deleted
FROM logs-*
| WHERE @timestamp > NOW() - 30 days AND event.dataset == "azure.auditlogs"
AND azure.auditlogs.operation_name IN ("Add member to role", "Remove service principal", "Delete application")
| EVAL actor_app = azure.auditlogs.properties.initiated_by.app.displayName, window = DATE_TRUNC(30 minutes, @timestamp)
| STATS grants = COUNT(*) WHERE azure.auditlogs.operation_name == "Add member to role",
cleanups = COUNT(*) WHERE azure.auditlogs.operation_name != "Add member to role",
first = MIN(@timestamp), last = MAX(@timestamp), ops = VALUES(azure.auditlogs.operation_name) BY actor_app, window
| WHERE grants > 0 AND cleanups > 0
| KEEP first, last, actor_app, ops
Issuance-then-use — tests the "no consent, no access" assumption instead of inheriting it (inferred; derived from the Chorus note above):
# stage 1: exported Entra SP sign-in logs, Oct 1 → now service_principal_id = 00000000-0000-0000-0000-000000000000 AND result = success → set of appIds that obtained a token under an unresolved service principal # stage 2: Graph activity logs, same window appId IN (stage 1) AND response_status 200–299 → any row = the app acted despite "no permissions"; review requestUri per appId # prerequisite: Microsoft Graph activity logs must be ENABLED (diagnostic setting) — Huntress # could not see them in affected tenants. If yours are off, stage 2 is a coverage gap, not a clean result # noise: run stage 1 on service-principal (app-only) sign-ins ONLY — Microsoft first-party # apps authenticate SP-less by design and surface in USER sign-in logs. The publisher is ON # THE SIGN-IN RECORD: Azure Monitor table AADServicePrincipalSignInLogs has column # AppOwnerTenantId, "the tenant identifier of the owner of the application" (confirmed against # the Learn schema page Oct 27). Graph API: signIn.appOwnerTenantId exists on BETA only — a v1.0 # pull will not have it. Event-hub/Splunk key `properties.appOwnerTenantId` is inferred from the # column name (Microsoft documents no sample record); tune to your add-on. Use it there — an # SP-less app has no service principal in your tenant to look up, and the MicrosoftGraphActivityLogs # schema has no owner or publisher column at all. Microsoft tenants (per Microsoft's own # verification page, which lists them as "some, but not all"): # f8cdef31-a31e-4b4a-93e4-5f571e91255a = "the Microsoft Service's Microsoft Entra tenant ID" # (Microsoft names no example apps for it) # 72f988bf-86f1-41af-91ab-2d7cd011db47 = tenant of "Microsoft tenant-owned applications" # (Graph Explorer, Microsoft Graph Command Line Tools) # These two are the documented ones; Microsoft operates more publisher tenants, so do not # treat the pair as complete. The set grows ONLY by Microsoft documentation: a documented owner # tenant, or an appId in Microsoft's service principal reference table # (learn.microsoft.com/entra/identity/monitoring-health/reference-service-principal-table, # "not exhaustive and will grow over time"). For any other owner tenant, name it with # GET /tenantRelationships/findTenantInformationByTenantId(tenantId='<appOwnerTenantId>') # but a Microsoft-looking displayName is free text and promotes nothing: hold it, do not drop # it. The daily-updated community list at github.com/merill/microsoft-info (keyed by appId) # is the cross-check.
# Splunk (Azure Monitor diagnostic export shape: category / properties.*; tune field
# names to your add-on). Publisher filter lives in the sign-in subsearch, not in stage 2.
index=* category=MicrosoftGraphActivityLogs earliest=-30d@d latest=now
[ search index=* category=ServicePrincipalSignInLogs resultType=0
properties.servicePrincipalId="00000000-0000-0000-0000-000000000000"
earliest=-30d@d latest=now
| where NOT 'properties.appOwnerTenantId' IN ("f8cdef31-a31e-4b4a-93e4-5f571e91255a",
"72f988bf-86f1-41af-91ab-2d7cd011db47")
| stats count by properties.appId
| fields properties.appId ]
| rename properties.appId as appId, properties.responseStatusCode as code,
properties.requestUri as uri
| where tonumber(code)>=200 AND tonumber(code)<300
| stats count as ok2xx values(uri) as uris min(_time) as first max(_time) as last by appId
// Elastic ES|QL (Azure Logs integration shape: azure.signinlogs.* and azure.graphactivitylogs.*;
// tune field names to your integration). No subsearch: both stages are read in one pass and
// joined by aggregation on appId — a row needs at least one zeros sign-in AND one 2xx Graph call.
// The owner-tenant key on the sign-in record is inferred from the Azure Monitor column name, as above.
FROM logs-*
| WHERE @timestamp > NOW() - 30 days
| WHERE (event.dataset == "azure.signinlogs" AND azure.signinlogs.category == "ServicePrincipalSignInLogs" AND event.outcome == "success"
AND azure.signinlogs.properties.service_principal_id == "00000000-0000-0000-0000-000000000000"
AND NOT azure.signinlogs.properties.app_owner_tenant_id IN ("f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"))
OR (event.dataset == "azure.graphactivitylogs" AND http.response.status_code >= 200 AND http.response.status_code < 300)
| EVAL app_id = COALESCE(azure.signinlogs.properties.app_id, azure.graphactivitylogs.properties.app_id)
| STATS zeros = COUNT(*) WHERE event.dataset == "azure.signinlogs", ok2xx = COUNT(*) WHERE event.dataset == "azure.graphactivitylogs",
uris = VALUES(url.original), first = MIN(@timestamp), last = MAX(@timestamp) BY app_id
| WHERE zeros > 0 AND ok2xx > 0
SP-less inventory — the class this incident belongs to. Pull the app-only sign-ins with the all-zeros ID, group by appId and the owner tenant printed on the record, and sort into three lanes. The full procedure, as four jobs (scoping, baselining, alerting, investigating) with the diagram and queries, is the companion note SP-less triage loop; it opens with a plain-language summary for readers new to service principals.
Lane 1 · Microsoft publisher
IgnoreMicrosoft's own apps authenticate without a service principal by design. The sign-in record's appOwnerTenantId identifies them when it is a tenant Microsoft documents, or the appId is in Microsoft's reference table. A Microsoft-looking tenant name admits nothing.
Lane 2 · Known vendor
WatchA grandfathered app reaching your tenant with no consent to revoke — a ConnectWise-shaped exposure. Admission is the vendor's exact defaultDomainName or a published appId, not a recognisable name. Re-check every baselined appId daily across all its sign-ins, zeros or real SP: new credential keyId, new resource, new ASN, failures. Hunt zeros → 2xx Graph activity (issuance-then-use) and the roles/credentials it touches (persistence audit).
Lane 3 · Unknown vendor
Diff daily, then file or blockAlert only on a new appId. Look it up: known → lane 2; Microsoft-named but undocumented, null owner, or no lookup result → hold; nobody's, lookup answered, second analyst → create a service principal for the appId, then disable it, record its objectId. Undo is deleting that SP. Breaks loudly if something depended on it — which is the point.
"Lateral movement" in this class is identity-side — role grants, credentials added to other apps, mailbox and Graph access — not host-to-host. The list is finite: Microsoft announced a block on SP-less auth for non-Microsoft multitenant apps for March 31, 2026, and its FAQ still lists "a few Microsoft and non-Microsoft applications" as exceptions; anything still doing it is one of those, so a non-Microsoft owner here is rare and high-prior. A zeros "Success" is issuance only; Graph is the one resource that then evaluates the empty token per request and logs it, which is why issuance-then-use runs on Graph activity. Scope: a vendor app you consented to has a real service principal and never appears in the zeros view; only the per-appId re-check sees its stolen credential used from new infrastructure.
# service-principal (app-only) sign-ins ONLY — user-log zeros are first-party apps refreshing
# tokens for users and carry the user's device IP; ignore them
index=* category=ServicePrincipalSignInLogs earliest=-90d@d
properties.servicePrincipalId="00000000-0000-0000-0000-000000000000"
| rename properties.appOwnerTenantId as owner_tenant, properties.appId as app_id,
properties.appDisplayName as app_name
| fillnull value="-" owner_tenant app_name
| eval bucket=case(
in(owner_tenant, "f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"), "microsoft",
owner_tenant="-", "hold: no owner on record",
true(), "resolve")
| stats count min(_time) as first max(_time) as last values(properties.resourceDisplayName) as resources
by bucket owner_tenant app_id app_name
| sort bucket
# fillnull matters: stats "by" silently drops rows where any by-field is null, so without it the
# no-owner rows never appear
# bucket "microsoft" = documented Microsoft owner: expected, file as lane 1 (cross-check a few by
# appId against Microsoft's service principal reference table / github.com/merill/microsoft-info)
# bucket "resolve": name owner_tenant with findTenantInformationByTenantId. Vendor's exact domain
# or published appId = lane 2 (its breach lands here with no consent to revoke). Microsoft-named
# but undocumented, lookalike domain, or no lookup result = hold. Nobody's, lookup answered,
# second analyst = lane 3: create a service principal for the appId, then disable it (Microsoft's
# documented block); verify via Graph that it exists with accountEnabled=false
# no Get-MgServicePrincipal step: an SP-less app has no service principal in your tenant to read
# Microsoft's own inventory call (reports FAQ, beta required; v1.0 returns no SP sign-ins):
# GET /beta/auditLogs/signIns?$filter=signInEventTypes/any(t: t eq 'servicePrincipal')
# and servicePrincipalId eq '00000000-0000-0000-0000-000000000000'
→ three jobs. SCOPING (daily): this query; a NEW (owner_tenant, appId) gets a lane. BASELINING (once per
laned appId): baseline 90 days of ALL its sign-ins, zeros or real SP — credential keyIds, resources,
ASNs, first seen — into the kept list. ALERTING (daily): every baselined appId against its row; new
credential keyId outranks new ASN; failures from a new ASN count; append to the baseline only when
a case closes, never rebuild it from raw.
// service-principal (app-only) sign-ins ONLY — user-log zeros are first-party apps refreshing
// tokens for users and carry the user's device IP; ignore them. Elastic ES|QL over the Azure Logs
// integration (azure.signinlogs.*); the owner-tenant key is inferred from the Azure Monitor column
// name, as in the Splunk block. STATS BY keeps null keys, so no fillnull step is needed here; the
// "-" stands in only so the bucket label reads the same.
FROM logs-*
| WHERE @timestamp > NOW() - 90 days AND event.dataset == "azure.signinlogs" AND azure.signinlogs.category == "ServicePrincipalSignInLogs"
AND azure.signinlogs.properties.service_principal_id == "00000000-0000-0000-0000-000000000000"
| EVAL owner_tenant = COALESCE(azure.signinlogs.properties.app_owner_tenant_id, "-"), app_id = azure.signinlogs.properties.app_id,
app_name = COALESCE(azure.signinlogs.properties.app_display_name, "-")
| EVAL bucket = CASE(
owner_tenant IN ("f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"), "microsoft",
owner_tenant == "-", "hold: no owner on record",
"resolve")
| STATS count = COUNT(*), first = MIN(@timestamp), last = MAX(@timestamp), resources = VALUES(azure.signinlogs.properties.resource_display_name)
BY bucket, owner_tenant, app_id, app_name
| SORT bucket
// the buckets, the lanes and the three jobs (scoping, baselining, alerting) are as described under
// the Splunk block: same lanes, same daily diff, same baseline per appId
Network fallback — if Entra retention fails you, the infrastructure is still checkable:
# firewall / proxy / flow logs dest IN (144.172.64.60, 144.172.64.99, 144.172.64.128, 144.172.94.243) → any match from identity infrastructure or egress since Oct 1
Microsoft Security Blog — Storm-0940 / ARTEX, Oct 21 · Huntress — vendor credential abuse · Huntress — scripts, people, and AI agents (Oct 26) · BleepingComputer · The Hacker News — ARTEX attribution, Oct 22 · Brandefense — tenant checks · Chorus Cyber — advisory, Oct 19 · Microsoft — retirement of service principal-less authentication · Microsoft Entra blog — service principal required · ConnectWise Trust Center — live advisory