Quem administra um tenant Microsoft Entra ID de médio ou grande porte sabe que ele acumula aplicativos como uma gaveta acumula cabos: com o tempo, ninguém lembra mais para que servem. Cada App Registration esquecida — muitas vezes com secret ou certificado expirado, criada para um POC que nunca foi para frente — é, ao mesmo tempo, desperdício de inventário e superfície de ataque. Identidades de aplicação são alvo recorrente em incidentes, e um app abandonado com permissões amplas é exatamente o tipo de porta dos fundos que ninguém está vigiando.
Para resolver essa demanda clássica de governança de identidade, desenvolvi com apoio do GitHub Copilot e disponibilizei em meu GitHub um Runbook de Azure Automation em PowerShell 7.2 que faz a varredura periódica do tenant, classifica cada aplicativo por atividade de sign-in e estado de credencial, e envia um relatório formatado por e-mail (HTML + CSV anexo) para o grupo de administradores.
A cada execução, a automação responde a três perguntas que todo time de segurança deveria conseguir responder a qualquer momento:
- Quais apps nunca tiveram sign-in registrado?
- Quais apps não logam há mais de N dias (janela configurável: 10 / 60 / 90)?
- Qual o estado da credencial (secret/cert) de cada um — atual, expirando ou expirada?
Por que uma abordagem híbrida (Graph + Log Analytics)?
A primeira tentação é resolver isso com uma query KQL “pura” sobre a tabela AADServicePrincipalSignInLogs. O problema é fatal: o KQL só enxerga apps que já logaram depois que a Diagnostic Setting foi habilitada. Justamente os apps abandonados há meses — os candidatos mais óbvios à remoção — nunca aparecem nessa consulta.
A segunda tentação é usar só o Microsoft Graph, via relatório servicePrincipalSignInActivities. Esse endpoint, porém, é agregado e eventualmente consistente, com atraso de horas a dias — não reflete a realidade imediata.
A solução combina o melhor dos dois mundos:
| Fonte | Função | Vantagem |
|---|---|---|
Microsoft Graph (Get-MgApplication) |
Inventário completo | Vê todos os apps, inclusive os nunca usados |
Log Analytics (AADServicePrincipalSignInLogs) |
Último sign-in | Dado fresco em minutos, sem latência |
O cruzamento por AppId produz a classificação correta — Ativo / Inativo / Nunca logou — mesmo no dia em que um app loga pela primeira vez.
Pré-requisitos
| Item | Requisito |
|---|---|
| Licença Entra ID | P1 ou P2 (para signInActivity e logs de sign-in de Service Principal) |
| Subscription Azure | Com permissão para criar Automation Account e Log Analytics |
| Conta executora | Privileged Role Administrator ou Global Admin (para conceder permissões de aplicação no Graph) |
| Mailbox de envio | Caixa Exchange Online com licença ativa (E1/E3/E5 ou Exchange Plan) |
| Domínio | Verificado no tenant (recomendado SPF/DKIM/DMARC para entrega) |
Para organizar todos os recursos da solução, comecei criando um Resource Group dedicado — no meu caso, RG-APPINATIVOS. Manter Automation Account, Log Analytics e identidades sob o mesmo grupo facilita o controle de custo e a limpeza futura.

Guia de implantação passo a passo
1. Diagnostic Setting do Entra ID
Tudo começa garantindo que os logs de sign-in cheguem ao Log Analytics. No portal, vá em Microsoft Entra ID → Monitoring → Diagnostic settings → Add diagnostic setting.

Dê um nome à configuração e marque as categorias de log necessárias:
- ✅
AuditLogs - ✅
SignInLogs - ✅
ServicePrincipalSignInLogs - ✅
MicrosoftServicePrincipalSignInLogs


MicrosoftServicePrincipalSignInLogs — ele captura sign-ins de apps da própria Microsoft/M365.Em Destination details, selecione Send to Log Analytics workspace e escolha (ou crie) o workspace de destino. No exemplo, usei o log-appinativos.

Importante: os logs só começam a fluir após o Save. As tabelas (
SigninLogs,AADServicePrincipalSignInLogsetc.) nascem no Log Analytics no primeiro evento ingerido — então elas só aparecerão na lista de Tables depois que houver sign-ins.
2. Workspace Log Analytics
Com o workspace recebendo dados, abra a lâmina de Overview dele para confirmar que está ativo e na região esperada.

Ajuste a retenção das tabelas para cobrir sua janela de análise. Para uma análise de 90 dias, defina retenção ≥ 90. Vá em Log Analytics workspace → Settings → Tables → Manage table em cada tabela relevante.



Dica: definir o padrão do workspace (em Usage and estimated costs → Data retention) é melhor do que ajustar tabela a tabela, porque tabelas novas — criadas quando o primeiro evento chegar — herdam o padrão automaticamente.
Por fim, anote o Workspace ID (Customer ID), que o Runbook vai usar. Se preferir o caminho via linha de comando, autentique-se no Azure CLI e recupere o GUID:

$ws = Get-AzOperationalInsightsWorkspace | Where-Object { $_.Name -eq "log-appinativos" }$ws.CustomerId.Guid

CustomerId.Guid é o Workspace ID que o Runbook consome.3. Azure Automation Account
Crie o Automation Account em Create a resource → Automation. Aponte para o Resource Group da solução e escolha a região — no exemplo, aa-appinativos em Canada Central.

Na aba Advanced, garanta que a Managed Identity → System assigned esteja habilitada. É ela que autentica o Runbook sem armazenar nenhuma credencial.

Após criar, abra o recurso. Aqui acessaremos os menus de Modules, Runbooks e Identity (anote o Object (principal) ID da identidade — ele será usado para conceder permissões).

4. Módulos do PowerShell
O Runbook depende de dois módulos do Graph. A ordem de importação importa, e o runtime precisa bater com o do Runbook (7.2): primeiro o Microsoft.Graph.Authentication, depois o Microsoft.Graph.Applications. Você pode usar a galeria do portal ou, de forma mais confiável, o Cloud Shell:
$rg = "RG-APPINATIVOS"$aa = "AA-APPINATIVOS"New-AzAutomationModule -ResourceGroupName $rg -AutomationAccountName $aa ` -Name "Microsoft.Graph.Authentication" ` -ContentLinkUri "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Authentication" ` -RuntimeVersion "7.2"# Aguarde 'Succeeded' antes de prosseguir:Get-AzAutomationModule -ResourceGroupName $rg -AutomationAccountName $aa ` -Name "Microsoft.Graph.Authentication" -RuntimeVersion "7.2"New-AzAutomationModule -ResourceGroupName $rg -AutomationAccountName $aa ` -Name "Microsoft.Graph.Applications" ` -ContentLinkUri "https://www.powershellgallery.com/api/v2/package/Microsoft.Graph.Applications" ` -RuntimeVersion "7.2"


ProvisioningState chegar a Succeeded antes de seguir.5. Permissões da Managed Identity
A managed identity precisa de quatro permissões de aplicação no Graph + uma role RBAC no workspace:
| Permissão | Tipo | Para que serve |
|---|---|---|
Application.Read.All |
Graph (App) | Ler inventário de App Registrations |
AuditLog.Read.All |
Graph (App) | Ler logs de auditoria |
Directory.Read.All |
Graph (App) | Ler service principals |
Mail.Send |
Graph (App) | Enviar e-mail via Graph |
Log Analytics Reader |
RBAC (workspace) | Executar queries KQL no workspace |
Autentique-se no Azure CLI e selecione a subscription correta:

Use o script scripts/01-grant-permissions.ps1 do repositório para conceder tudo de uma vez. Preencha o $miObjectId (o Object ID anotado no passo 3) e o nome do workspace:
# Permissões Graph (Application)Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All","Application.Read.All"$miObjectId = "<OBJECT_ID_DA_MANAGED_IDENTITY>"$graph = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"$permissoes = "Application.Read.All","AuditLog.Read.All","Directory.Read.All","Mail.Send"foreach ($p in $permissoes) { $role = $graph.AppRoles | Where-Object { $_.Value -eq $p -and $_.AllowedMemberTypes -contains "Application" } New-MgServicePrincipalAppRoleAssignment ` -ServicePrincipalId $miObjectId ` -PrincipalId $miObjectId ` -ResourceId $graph.Id ` -AppRoleId $role.Id Write-Host "Concedido: $p" -ForegroundColor Green}# RBAC no workspace Log Analytics$ws = Get-AzOperationalInsightsWorkspace | Where-Object { $_.Name -eq "log-appinativos" }New-AzRoleAssignment ` -ObjectId $miObjectId ` -RoleDefinitionName "Log Analytics Reader" ` -Scope $ws.ResourceId



Na primeira execução, o Microsoft Graph Command Line Tools pedirá consentimento para acessar as informações — aceite para prosseguir.

Ao final, a saída confirma cada permissão concedida e exibe o Workspace Customer ID:

Atenção ao
Mail.Send: como permissão de aplicação, ele permite enviar como qualquer caixa do tenant. Em produção, aplique uma Application Access Policy do Exchange Online para restringir o envio à mailbox específica.Propagação: permissões recém-concedidas podem levar 15–30 minutos para refletir no token. Se o primeiro teste retornar
Forbidden, aguarde e tente de novo.
6. Importar o Runbook
No Automation Account, vá em Runbooks → Create a runbook.

Preencha o formulário:
- Name:
rb-appinativos - Runbook type: PowerShell
- Runtime version: 7.2

Cole o conteúdo de scripts/RB_AppsInativos.ps1 no editor e ajuste os parâmetros default no topo do script (ou passe-os depois via Test pane / Schedule):
param( [int] $DiasSemUso = 90, [string] $WorkspaceId = "<WORKSPACE_CUSTOMER_ID>", [string] $Remetente = "automation@suaempresa.com", [string] $Destinatario = "admins@suaempresa.com")

param pronto para ajuste.Clique em Publish.



rb-appinativos.7. Validação no Test Pane
No Runbook publicado, abra o Test pane, preencha os parâmetros e clique em Start.

A saída esperada segue mais ou menos esta ordem:
Conectado ao Microsoft Graph.Log Analytics: ultimo login carregado para N apps.Inventario: N app registrations.Inativos ou nunca logados: X de Y apps.CSV gerado: X linhas, N bytes.Email enviado para admins@suaempresa.com com anexo apps-inativos-YYYYMMDD.csv.
E o resultado chega na caixa de entrada: um e-mail HTML com tabela colorida classificando cada aplicativo, mais o CSV anexo para abrir no Excel.

Confira também a pasta de spam — domínios sem SPF/DKIM completos costumam cair em spam no Hotmail/Gmail.
8. Agendamento
Para execução recorrente sem intervenção manual, vá em Schedules → Add a schedule.

Defina nome, início, fuso e recorrência. No exemplo, criei o Recorrencia_30dias, mensal, no fuso de Brasília:

Depois, é só vincular o schedule ao Runbook (Link to schedule) e configurar os mesmos parâmetros do Test pane.

Como o script funciona (por dentro)
Cada bloco do RB_AppsInativos.ps1 tem uma responsabilidade isolada:
| Bloco | O que faz |
|---|---|
| 1. Connect-MgGraph -Identity | Autentica como managed identity no Graph |
| 2. Token do Log Analytics | Pega token via IDENTITY_ENDPOINT (não usa IMDS, que não funciona no sandbox do Automation) |
| 3. Query KQL via REST | union entre AADServicePrincipalSignInLogs e SigninLogs → último login por AppId |
| 4. Inventário | Get-MgApplication -All traz todos os App Registrations + credenciais |
| 5. Classificação | Cruza inventário com hash de sign-ins; define Status e Credencial |
| 6. CSV em memória | Gera CSV com separador ; e BOM UTF-8 (Excel PT-BR friendly) |
| 7. HTML + anexo | Monta o body HTML com tabela colorida e anexa o CSV em base64 |
| 8. sendMail via REST | Pega token fresco do Graph via IDENTITY_ENDPOINT (o Connect-MgGraph pode usar token em cache sem Mail.Send) |
Vale destacar duas particularidades técnicas que costumam tropeçar quem reproduz esse padrão. Primeiro, o uso do IDENTITY_ENDPOINT em vez do clássico 169.254.169.254: o endpoint IMDS só funciona em VMs e Hybrid Workers; no sandbox padrão do Azure Automation, a managed identity expõe o token por duas variáveis de ambiente (IDENTITY_ENDPOINT e IDENTITY_HEADER) — é o método oficial e suportado. Segundo, pegar um token fresco antes do sendMail: como o Connect-MgGraph -Identity cacheia o token, se o Mail.Send foi concedido depois desse cache, o token em uso não tem o scope e o Graph retorna Forbidden. Um token novo via REST elimina o problema.
Resolução de problemas
| Sintoma | Causa provável | Solução |
|---|---|---|
Connect-MgGraph is not recognized |
Módulos não importados ou runtime errado | Passo 4: confirme runtime 7.2 e status Available |
| Tabelas de sign-in não existem | Diagnostic Setting não salva ou sem eventos | Passo 1; force um sign-in para criar a tabela |
Query KQL retorna No results |
Janela curta ou logs ainda não ingeridos | Aguarde 5–30 min após o primeiro sign-in |
Invalid URI: hostname could not be parsed |
Rodou bloco do Runbook no Cloud Shell | IDENTITY_ENDPOINT só existe no sandbox |
sendMail → Forbidden |
Mail.Send não propagou ou fora do token |
Aguarde 30 min ou use token fresco via REST |
sendMail → MailboxNotEnabledForRESTAPI |
Remetente sem licença Exchange | Atribua licença E1/E3/E5 ou use outra caixa |
| E-mail cai no spam | Domínio sem SPF/DKIM/DMARC | Configure os registros DNS do domínio do remetente |
Boas práticas de produção
Antes de levar isso para o ambiente real, alguns cuidados fazem diferença. Restrinja o Mail.Send com uma Application Access Policy no Exchange Online, limitando a managed identity a enviar apenas pela mailbox de automação — sem isso, ela pode tecnicamente enviar como qualquer caixa. Quarentene antes de excluir: o relatório identifica candidatos, mas a remoção deve ser por etapas — detectar (este Runbook), desativar o app por 30 dias (Update-MgServicePrincipal -AccountEnabled:$false) e, sem incidentes, excluir. Filtre apps de sistema (Microsoft, Office etc.) pelo publisherDomain ou por uma whitelist, para não poluir o relatório. Monitore o próprio Runbook com um alerta no Azure Monitor para falhas do job — se ele quebrar em silêncio, a higiene de identidade volta a degradar. E, por fim, versione via Source Control, integrando o Automation Account ao repositório do GitHub.
Contribua com a comunidade!
Este repositório é open-source (licença MIT) e focado na evolução contínua dos profissionais de nuvem. Se você identificar uma melhoria — filtro de apps de sistema, suporte a múltiplos destinatários e grupos do M365, tabela em Application Insights / Workbook do Sentinel, ou até uma variante que desativa automaticamente apps com mais de 180 dias inativos — fique à vontade para abrir uma Issue ou enviar um Pull Request.
Se este projeto te ajudou a fortalecer a governança de identidade do seu tenant, acesse o link github.com/ErickMedeiros/entra-apps-inativos, deixe uma ⭐ e ajude a fazer com que ele chegue a mais profissionais!
Forte abraço pessoal 👊


