<# .SYNOPSIS Radar - escaneo de gobierno de Power Platform y Copilot Studio (SOLO LECTURA). .DESCRIPTION Lee entornos, apps, flujos, politicas DLP, conectores personalizados, agentes y licencias de tu tenant y genera un JSON anonimizado en tu maquina. No envia nada: tu revisas el archivo y lo subes tu. Compatible con Windows PowerShell 5.1 y PowerShell 7. .PARAMETER Salida Carpeta de salida (por defecto .\scanner-output). El archivo se llama .radar.json. .PARAMETER ReporteLicenciasME Ruta opcional al CSV del Centro de administracion de Power Platform: Licencias > Power Apps > "Users requiring licenses in Managed Environments" (Descargar reporte). .PARAMETER SinGraph Omite los modulos de Microsoft Graph (sin estado de propietarios ni licencias). .PARAMETER Ayuda Muestra esta ayuda. .EXAMPLE .\radar-scan.ps1 .EXAMPLE .\radar-scan.ps1 -ReporteLicenciasME .\licencias-me.csv -Salida C:\Temp\radar #> [CmdletBinding()] param( [string]$Salida = '.\scanner-output', [string]$ReporteLicenciasME, [switch]$SinGraph, [switch]$Ayuda, [switch]$SoloFunciones # uso interno: carga las funciones sin ejecutar (pruebas Pester) ) $ErrorActionPreference = 'Stop' $script:Version = '0.1.0' $script:Advertencias = [System.Collections.Generic.List[string]]::new() # ---------------------------------------------------------------- utilidades puras (probadas con Pester) # Hash anonimo: sha256(sal + valor en minusculas), primeros 16 hex. Determinista para una misma sal. function Get-HashAnonimo { param([string]$Valor, [Parameter(Mandatory)][string]$Sal) if ([string]::IsNullOrWhiteSpace($Valor)) { return $null } $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Sal + $Valor.Trim().ToLowerInvariant()) $hex = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) } finally { $sha.Dispose() } return $hex.Substring(0, 16) } # Sal aleatoria por ejecucion (solo vive en memoria; no se escribe en el JSON). function Get-SalEscaneo { $b = [byte[]]::new(32) $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() try { $rng.GetBytes($b) } finally { $rng.Dispose() } return [Convert]::ToBase64String($b) } # Lee una propiedad anidada ("a.b.c") sin fallar si algo falta. function Get-Valor { param($Objeto, [string]$Ruta) $actual = $Objeto foreach ($p in $Ruta.Split('.')) { if ($null -eq $actual) { return $null } try { $actual = $actual.$p } catch { return $null } } return $actual } function ConvertTo-IsoUtc { param($Fecha) if ($null -eq $Fecha -or "$Fecha" -eq '') { return $null } try { $d = [datetime]$Fecha return $d.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) } catch { return $null } } function Get-IdConector { param([string]$Texto) if ([string]::IsNullOrWhiteSpace($Texto)) { return $null } return ($Texto.Trim().TrimEnd('/') -split '/')[-1].ToLowerInvariant() } # Extrae los ids de conector (shared_xxx) de las referencias de conexion de una app o flujo. function Get-ConectoresDeReferencias { param($Referencias) $ids = [System.Collections.Generic.List[string]]::new() if ($null -eq $Referencias) { return @() } foreach ($prop in $Referencias.PSObject.Properties) { $r = $prop.Value $id = Get-Valor $r 'id' if (-not $id) { $id = Get-Valor $r 'apiId' } if (-not $id) { $id = Get-Valor $r 'connectorId' } $c = Get-IdConector ([string]$id) if ($c -and -not $ids.Contains($c)) { $ids.Add($c) } } return [string[]]$ids.ToArray() } # Identifica a un usuario (UPN o correo, y objectId) a partir de un objeto Owner/CreatedBy. function Get-IdentidadUsuario { param($Usuario) if ($null -eq $Usuario) { return $null } $upn = Get-Valor $Usuario 'userPrincipalName' if (-not $upn) { $upn = Get-Valor $Usuario 'email' } if (-not $upn) { $upn = Get-Valor $Usuario 'mail' } $id = Get-Valor $Usuario 'id' if (-not $id) { $id = Get-Valor $Usuario 'objectId' } $tipo = Get-Valor $Usuario 'type' if (-not $upn -and -not $id) { return $null } return [pscustomobject]@{ Upn = [string]$upn; Id = [string]$id; Tipo = [string]$tipo } } # Dominio del UPN (para distinguir invitados); para invitados "x_dominio.com#EXT#@tenant" devuelve dominio.com. function Get-DominioUpn { param([string]$Upn) if ([string]::IsNullOrWhiteSpace($Upn) -or $Upn -notmatch '@') { return $null } if ($Upn -match '^(.+)_([^_@#]+)#EXT#@') { return $Matches[2].ToLowerInvariant() } return ($Upn.Split('@')[-1]).ToLowerInvariant() } function Get-ClaveHashUsuario { param($Identidad, [string]$Sal) if ($null -eq $Identidad) { return $null } $k = $Identidad.Upn if (-not $k) { $k = $Identidad.Id } return Get-HashAnonimo -Valor $k -Sal $Sal } # ---------------------------------------------------------------- conversores a EscaneoV1 function Convert-Entorno { param($Entorno, [string]$Sal) $tipoOrigen = [string](Get-Valor $Entorno 'EnvironmentType') $esDefault = [bool](Get-Valor $Entorno 'IsDefault') if ($esDefault) { $tipo = 'Default' } else { switch -Regex ($tipoOrigen) { '^Production$' { $tipo = 'Production'; break } '^Sandbox$' { $tipo = 'Sandbox'; break } '^Developer$' { $tipo = 'Developer'; break } '^(Trial|SubscriptionBasedTrial)$' { $tipo = 'Trial'; break } '^Teams$' { $tipo = 'Teams'; break } default { $tipo = 'Otro' } } } # Managed Environments: governanceConfiguration.protectionLevel = "Standard" (administrado); "Basic" = no administrado. $nivel = [string](Get-Valor $Entorno 'Internal.properties.governanceConfiguration.protectionLevel') $managed = ($nivel -ne '' -and $nivel -ne 'Basic') $db = [string](Get-Valor $Entorno 'CommonDataServiceDatabaseType') $linked = Get-Valor $Entorno 'Internal.properties.linkedEnvironmentMetadata' $dataverse = ($null -ne $linked) -or ($db -ne '' -and $db -ne 'None') $sg = [string](Get-Valor $Entorno 'Internal.properties.linkedEnvironmentMetadata.securityGroupId') $seguridadGrupo = ($sg -ne '' -and $sg -ne '00000000-0000-0000-0000-000000000000') $creador = Get-IdentidadUsuario (Get-Valor $Entorno 'CreatedBy') $ultima = Get-Valor $Entorno 'LastModifiedTime' if (-not $ultima) { $ultima = Get-Valor $Entorno 'Internal.properties.lastModifiedTime' } return [ordered]@{ id = [string](Get-Valor $Entorno 'EnvironmentName') nombre_hash = Get-HashAnonimo -Valor ([string](Get-Valor $Entorno 'DisplayName')) -Sal $Sal tipo = $tipo region = [string](Get-Valor $Entorno 'Location') managed = $managed dataverse = $dataverse creado_en = ConvertTo-IsoUtc (Get-Valor $Entorno 'CreatedTime') creador_hash = Get-ClaveHashUsuario $creador $Sal seguridad_grupo = $seguridadGrupo ultima_actividad = ConvertTo-IsoUtc $ultima } } # Resumen de comparticion a partir de las asignaciones de rol de la app. function Get-ResumenCompartido { param($Asignaciones) $u = 0; $g = 0; $t = $false foreach ($a in @($Asignaciones)) { if ($null -eq $a) { continue } if ([string](Get-Valor $a 'RoleType') -eq 'Owner') { continue } switch ([string](Get-Valor $a 'PrincipalType')) { 'Group' { $g++ } 'Tenant' { $t = $true } default { $u++ } } } return [ordered]@{ usuarios = $u; grupos = $g; todo_el_tenant = $t } } function Convert-App { param($App, [string]$Sal, [scriptblock]$ResolverEstado, $Compartida) $dueno = Get-IdentidadUsuario (Get-Valor $App 'Owner') $estado = 'desconocido' if ($dueno -and $ResolverEstado) { $estado = & $ResolverEstado $dueno } $tipoApp = [string](Get-Valor $App 'Internal.properties.appType') $tipo = 'canvas' if ($tipoApp -match 'Model') { $tipo = 'model' } if ($null -eq $Compartida) { $Compartida = [ordered]@{ usuarios = 0; grupos = 0; todo_el_tenant = $false } } $refs = Get-Valor $App 'Internal.properties.connectionReferences' return [ordered]@{ id = [string](Get-Valor $App 'AppName') entorno_id = [string](Get-Valor $App 'EnvironmentName') tipo = $tipo propietario_hash = Get-ClaveHashUsuario $dueno $Sal propietario_estado = $estado compartida_con = $Compartida conectores = [string[]]@(Get-ConectoresDeReferencias $refs) ultima_modificacion = ConvertTo-IsoUtc (Get-Valor $App 'LastModifiedTime') ultimo_uso = $null # el cmdlet de administracion no expone el uso; requiere analitica de uso } } function Convert-Flujo { param($Flujo, [string]$Sal, [scriptblock]$ResolverEstado) $dueno = Get-IdentidadUsuario (Get-Valor $Flujo 'CreatedBy') $estado = 'desconocido' if ($dueno -and $ResolverEstado) { $estado = & $ResolverEstado $dueno } $st = [string](Get-Valor $Flujo 'Internal.properties.state') switch ($st) { 'Started' { $est = 'Started' } 'Stopped' { $est = 'Stopped' } 'Suspended' { $est = 'Suspended' } default { if ($st -eq '' -and (Get-Valor $Flujo 'Enabled') -eq $true) { $est = 'Started' } elseif ($st -eq '' -and (Get-Valor $Flujo 'Enabled') -eq $false) { $est = 'Stopped' } else { $est = 'Otro' } } } $disp = $null $triggers = Get-Valor $Flujo 'Internal.properties.definitionSummary.triggers' if ($triggers) { $t0 = @($triggers)[0]; $disp = [string](Get-Valor $t0 'type'); if (-not $disp) { $disp = [string](Get-Valor $t0 'kind') } } if ($disp -eq '') { $disp = $null } return [ordered]@{ id = [string](Get-Valor $Flujo 'FlowName') entorno_id = [string](Get-Valor $Flujo 'EnvironmentName') estado = $est propietario_hash = Get-ClaveHashUsuario $dueno $Sal propietario_estado = $estado conectores = [string[]]@(Get-ConectoresDeReferencias (Get-Valor $Flujo 'Internal.properties.connectionReferences')) disparador = $disp ultima_modificacion = ConvertTo-IsoUtc (Get-Valor $Flujo 'LastModifiedTime') ultima_ejecucion = $null # requiere historial de ejecuciones (no se consulta) fallos_30d = $null } } function Convert-Dlp { param($Politica, [string]$Sal) $filtro = [string](Get-Valor $Politica 'FilterType') switch ($filtro) { 'OnlyEnvironments' { $alcance = 'entornos_incluidos' } 'ExceptEnvironments' { $alcance = 'entornos_excluidos' } default { $alcance = 'tenant' } } $envs = @() if ($alcance -ne 'tenant') { foreach ($e in @(Get-Valor $Politica 'Environments')) { $n = [string](Get-Valor $e 'name'); if (-not $n) { $n = [string](Get-Valor $e 'EnvironmentName') } if ($n) { $envs += $n } } } $neg = [System.Collections.Generic.List[string]]::new() $non = [System.Collections.Generic.List[string]]::new() $blo = [System.Collections.Generic.List[string]]::new() $grupos = Get-Valor $Politica 'ConnectorGroups' if ($grupos) { foreach ($gr in @($grupos)) { $dest = $non switch ([string](Get-Valor $gr 'classification')) { 'Confidential' { $dest = $neg } 'Blocked' { $dest = $blo } default { $dest = $non } } foreach ($c in @(Get-Valor $gr 'connectors')) { $id = Get-IdConector ([string](Get-Valor $c 'id')); if ($id) { $dest.Add($id) } } } } else { foreach ($c in @(Get-Valor $Politica 'BusinessDataGroup')) { $id = Get-IdConector ([string](Get-Valor $c 'id')); if ($id) { $neg.Add($id) } } foreach ($c in @(Get-Valor $Politica 'NonBusinessDataGroup')) { $id = Get-IdConector ([string](Get-Valor $c 'id')); if ($id) { $non.Add($id) } } foreach ($c in @(Get-Valor $Politica 'BlockedGroup')) { $id = Get-IdConector ([string](Get-Valor $c 'id')); if ($id) { $blo.Add($id) } } } $def = [string](Get-Valor $Politica 'DefaultConnectorsClassification') switch ($def) { 'Confidential' { $dg = 'negocio' } 'Blocked' { $dg = 'bloqueado' } default { $dg = 'no_negocio' } } $nombre = [string](Get-Valor $Politica 'DisplayName') if (-not $nombre) { $nombre = [string](Get-Valor $Politica 'PolicyName') } return [ordered]@{ id = [string](Get-Valor $Politica 'PolicyName') nombre_hash = Get-HashAnonimo -Valor $nombre -Sal $Sal alcance = $alcance entornos = [string[]]$envs conectores_negocio = [string[]]$neg.ToArray() conectores_no_negocio = [string[]]$non.ToArray() conectores_bloqueados = [string[]]$blo.ToArray() default_group = $dg } } function Convert-ConectorCustom { param($Conector) $url = [string](Get-Valor $Conector 'Internal.properties.backendService.serviceUrl') $host_ = '' try { $u = [System.Uri]$url $host_ = $u.Host if ($u.Scheme -eq 'http') { $host_ = 'http://' + $u.Host } } catch { $host_ = '' } $params = Get-Valor $Conector 'Internal.properties.connectionParameters' $json = '' if ($params) { try { $json = ($params | ConvertTo-Json -Depth 6 -Compress) } catch { $json = '' } } if ($json -match 'oauth|token') { $aut = 'oauth2' } elseif ($json -match 'api_?key|apiKey') { $aut = 'apikey' } elseif ($json -match 'username|password|basic') { $aut = 'basica' } elseif ($json -eq '' -or $json -eq '{}') { $aut = 'ninguna' } else { $aut = 'otra' } return [ordered]@{ id = [string](Get-Valor $Conector 'ConnectorName') entorno_id = [string](Get-Valor $Conector 'EnvironmentName') host = $host_ autenticacion = $aut } } # Interpreta el texto de "pac copilot list": cada linea con un GUID es un agente (id; el resto se descarta). function Convert-SalidaPacAgentes { param([string[]]$Lineas, [string]$EntornoId) $res = @() foreach ($l in $Lineas) { if ($l -match '([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})') { $res += [ordered]@{ id = $Matches[1].ToLowerInvariant() entorno_id = $EntornoId propietario_hash = $null propietario_estado = 'desconocido' publicado = $false # "pac copilot list" no informa el estado de publicacion canales = [string[]]@() autenticacion = 'desconocida' acciones_conectores = [string[]]@() conocimiento_web = $false ultima_modificacion = $null } } } return $res } # Reporte "Users requiring licenses in Managed Environments" del PPAC: devuelve solo conteos y ids de entorno. function Read-ReporteLicenciasME { param([Parameter(Mandatory)][string]$Ruta, [string]$Sal) $lineas = [System.IO.File]::ReadAllLines($Ruta, [System.Text.Encoding]::UTF8) if ($lineas.Count -lt 2) { throw 'El CSV esta vacio.' } $delim = ',' if ($lineas[0].Split(';').Count -gt $lineas[0].Split(',').Count) { $delim = ';' } $filas = @($lineas | ConvertFrom-Csv -Delimiter $delim) $cols = @($filas[0].PSObject.Properties.Name) $colUsuario = $cols | Where-Object { $_ -match '(?i)upn|user|usuario|email|mail' } | Select-Object -First 1 $colEntorno = $cols | Where-Object { $_ -match '(?i)environment.*(id|name)|entorno' } | Select-Object -First 1 $colLic = $cols | Where-Object { $_ -match '(?i)licensed|has.*licen|tiene.*licen' } | Select-Object -First 1 $usuarios = @{} $entornos = @{} foreach ($f in $filas) { if ($colLic) { $v = ([string]$f.$colLic).Trim().ToLowerInvariant() if ($v -in @('yes', 'true', 'si', 'si', '1', 'licensed')) { continue } } $n++; $clave = if ($colUsuario) { [string]$f.$colUsuario } else { "fila$n" } if ($clave) { $usuarios[$clave.ToLowerInvariant()] = $true } if ($colEntorno) { $e = ([string]$f.$colEntorno).Trim() if ($e) { if ($e -notmatch '^[0-9a-fA-F-]{36}$') { $e = Get-HashAnonimo -Valor $e -Sal $Sal } $entornos[$e.ToLowerInvariant()] = $true } } } $mes = (Get-Item -LiteralPath $Ruta).LastWriteTime.ToString('yyyy-MM', [System.Globalization.CultureInfo]::InvariantCulture) return [ordered]@{ usuarios_sin_licencia = [int]$usuarios.Count entornos = [string[]]@($entornos.Keys | Sort-Object) mes = $mes } } # Serializa el escaneo a JSON (UTF-8 sin BOM lo escribe quien llama). function ConvertTo-EscaneoJson { param([Parameter(Mandatory)]$Escaneo) return (ConvertTo-Json -InputObject $Escaneo -Depth 12) } function Write-ArchivoUtf8SinBom { param([string]$Ruta, [string]$Texto) [System.IO.File]::WriteAllText($Ruta, $Texto, ([System.Text.UTF8Encoding]::new($false))) } # ---------------------------------------------------------------- ejecucion function Show-Ayuda { Write-Host @' Radar - escaneo de gobierno de Power Platform (solo lectura) Uso: .\radar-scan.ps1 [-Salida ] [-ReporteLicenciasME ] [-SinGraph] [-Ayuda] -Salida Carpeta de salida (por defecto .\scanner-output) -ReporteLicenciasME CSV del PPAC: Licencias > Power Apps > "Users requiring licenses in Managed Environments" -SinGraph No usar Microsoft Graph (sin estado de propietarios ni licencias) -Ayuda Muestra esta ayuda Requisitos: modulo Microsoft.PowerApps.Administration.PowerShell y rol Power Platform Administrator. Opcionales: PAC CLI (agentes) y Microsoft.Graph.Users / Microsoft.Graph.Identity.DirectoryManagement (propietarios y licencias). Mas detalle en scanner\README.md '@ } function Test-ModuloDisponible { param([string]$Nombre) return [bool](Get-Module -ListAvailable -Name $Nombre) } function Get-VersionModulo { param([string]$Nombre) $m = Get-Module -ListAvailable -Name $Nombre | Sort-Object Version -Descending | Select-Object -First 1 if ($m) { return [string]$m.Version } return 'no instalado' } function Show-Advertencia { param([string]$Texto) $script:Advertencias.Add($Texto) Write-Host (" [aviso] " + $Texto) -ForegroundColor Yellow } function Invoke-Principal { if ($Ayuda) { Show-Ayuda; return } $sal = Get-SalEscaneo $inicio = Get-Date Write-Host '' Write-Host 'Radar - escaneo de solo lectura' -ForegroundColor Cyan Write-Host 'No se modifica nada en tu tenant y no se envia nada: el JSON queda en tu equipo.' -ForegroundColor DarkGray # --- 1. Modulo obligatorio $modPP = 'Microsoft.PowerApps.Administration.PowerShell' if (-not (Test-ModuloDisponible $modPP)) { Write-Host "Falta el modulo $modPP. Instalalo (sin permisos de administrador) y vuelve a correr:" -ForegroundColor Red Write-Host " Install-Module -Name $modPP -Scope CurrentUser" -ForegroundColor Yellow exit 1 } Import-Module $modPP -ErrorAction Stop $modulos = [ordered]@{} $modulos[$modPP] = Get-VersionModulo $modPP # --- 2. Graph (opcional) $conGraph = $false $graphLic = $false if (-not $SinGraph) { $mUsers = 'Microsoft.Graph.Users' $mDir = 'Microsoft.Graph.Identity.DirectoryManagement' $mAut = 'Microsoft.Graph.Authentication' if ((Test-ModuloDisponible $mUsers) -and (Test-ModuloDisponible $mAut)) { try { Import-Module $mAut, $mUsers -ErrorAction Stop $modulos[$mUsers] = Get-VersionModulo $mUsers if (Test-ModuloDisponible $mDir) { Import-Module $mDir -ErrorAction Stop; $modulos[$mDir] = Get-VersionModulo $mDir; $graphLic = $true } else { Show-Advertencia "Falta $mDir (licencias). Instalalo con: Install-Module -Name $mDir -Scope CurrentUser" } Write-Host 'Inicio de sesion en Microsoft Graph (permisos de solo lectura)...' -ForegroundColor Cyan Connect-MgGraph -Scopes 'User.Read.All', 'Organization.Read.All', 'AuditLog.Read.All' -NoWelcome | Out-Null $conGraph = $true } catch { Show-Advertencia ("No se pudo iniciar Graph: " + $_.Exception.Message + " Se continua sin propietarios ni licencias.") } } else { Show-Advertencia "Graph no disponible. Para estado de propietarios y licencias: Install-Module -Name Microsoft.Graph.Users, Microsoft.Graph.Identity.DirectoryManagement -Scope CurrentUser (o usa -SinGraph)." } } # --- 3. Inicio de sesion en Power Platform (interactivo) Write-Host 'Inicio de sesion en Power Platform...' -ForegroundColor Cyan Add-PowerAppsAccount | Out-Null $alcance = [ordered]@{ entornos = $false; apps = $false; flujos = $false; agentes = $false; dlp = $false; licencias = $false; conectores_custom = $false; licencias_me = $false } $dominios = @{} $cacheEstado = @{} # Estado del propietario (activo / deshabilitado / no_existe / desconocido) con cache. $resolver = { param($u) if ($u.Tipo -match '(?i)serviceprincipal') { return 'activo' } if (-not $conGraph) { return 'desconocido' } $q = if ($u.Id -match '^[0-9a-fA-F-]{36}$') { $u.Id } else { $u.Upn } if (-not $q) { return 'desconocido' } if ($cacheEstado.ContainsKey($q)) { return $cacheEstado[$q] } $r = 'desconocido' try { $mg = Get-MgUser -UserId $q -Property 'Id,AccountEnabled' -ErrorAction Stop if ($mg.AccountEnabled -eq $false) { $r = 'deshabilitado' } else { $r = 'activo' } } catch { if ($_.Exception.Message -match 'NotFound|does not exist|Request_ResourceNotFound|404') { $r = 'no_existe' } } $cacheEstado[$q] = $r return $r }.GetNewClosure() # --- 4. Entornos Write-Host 'Leyendo entornos...' -ForegroundColor Cyan $entornosRaw = @() try { $entornosRaw = @(Get-AdminPowerAppEnvironment) $alcance.entornos = $true } catch { Show-Advertencia ("No se pudieron leer los entornos: " + $_.Exception.Message) } $entornos = @($entornosRaw | ForEach-Object { Convert-Entorno -Entorno $_ -Sal $sal }) foreach ($e in $entornosRaw) { $d = Get-DominioUpn (Get-IdentidadUsuario (Get-Valor $e 'CreatedBy')).Upn if ($d) { $dominios[$d] = $true } } $tenantId = [string](Get-Valor ($entornosRaw | Select-Object -First 1) 'Internal.properties.tenantId') # --- 5. Apps Write-Host 'Leyendo apps...' -ForegroundColor Cyan $apps = @() $appsRaw = @() try { $appsRaw = @(Get-AdminPowerApp) $alcance.apps = $true } catch { Show-Advertencia ("No se pudieron leer las apps: " + $_.Exception.Message) } $i = 0 foreach ($a in $appsRaw) { $i++ if ($i % 20 -eq 0) { Write-Progress -Activity 'Apps' -Status "$i de $($appsRaw.Count)" -PercentComplete ([int](100 * $i / [math]::Max(1, $appsRaw.Count))) } $comp = $null if ($i -le 2000) { try { $comp = Get-ResumenCompartido (Get-AdminPowerAppRoleAssignment -AppName $a.AppName -EnvironmentName $a.EnvironmentName -ErrorAction Stop) } catch { $comp = $null } } $apps += , (Convert-App -App $a -Sal $sal -ResolverEstado $resolver -Compartida $comp) $d = Get-DominioUpn (Get-IdentidadUsuario (Get-Valor $a 'Owner')).Upn if ($d) { $dominios[$d] = $true } } Write-Progress -Activity 'Apps' -Completed if ($appsRaw.Count -gt 2000) { Show-Advertencia 'Mas de 2000 apps: el detalle de comparticion se leyo solo para las primeras 2000.' } # --- 6. Flujos Write-Host 'Leyendo flujos...' -ForegroundColor Cyan $flujos = @() $flujosRaw = @() try { $flujosRaw = @(Get-AdminFlow) $alcance.flujos = $true } catch { Show-Advertencia ("No se pudieron leer los flujos: " + $_.Exception.Message) } foreach ($f in $flujosRaw) { $flujos += , (Convert-Flujo -Flujo $f -Sal $sal -ResolverEstado $resolver) $d = Get-DominioUpn (Get-IdentidadUsuario (Get-Valor $f 'CreatedBy')).Upn if ($d) { $dominios[$d] = $true } } # --- 7. DLP Write-Host 'Leyendo politicas DLP...' -ForegroundColor Cyan $dlp = @() try { $pols = @() try { $pols = @(Get-DlpPolicy) } catch { $pols = @(Get-AdminDlpPolicy) } $dlp = @($pols | ForEach-Object { Convert-Dlp -Politica $_ -Sal $sal }) $alcance.dlp = $true } catch { Show-Advertencia ("No se pudieron leer las politicas DLP: " + $_.Exception.Message) } # --- 8. Conectores personalizados Write-Host 'Leyendo conectores personalizados...' -ForegroundColor Cyan $custom = @() try { $custom = @(Get-AdminPowerAppConnector | ForEach-Object { Convert-ConectorCustom -Conector $_ }) $alcance.conectores_custom = $true } catch { Show-Advertencia ("No se pudieron leer los conectores personalizados: " + $_.Exception.Message) } # --- 9. Agentes de Copilot Studio (PAC CLI) Write-Host 'Leyendo agentes de Copilot Studio...' -ForegroundColor Cyan $agentes = @() $pac = Get-Command pac -ErrorAction SilentlyContinue if ($pac) { $modulos['pac'] = 'instalado' $ok = 0 foreach ($e in ($entornos | Where-Object { $_.dataverse })) { try { $salida = & pac copilot list --environment $e.id 2>&1 | ForEach-Object { "$_" } if ($LASTEXITCODE -ne 0) { throw (($salida | Select-Object -First 2) -join ' ') } $agentes += Convert-SalidaPacAgentes -Lineas $salida -EntornoId $e.id $ok++ } catch { Show-Advertencia ("pac copilot list fallo en un entorno: " + $_.Exception.Message) } } $alcance.agentes = ($ok -gt 0) if ($ok -eq 0) { Show-Advertencia 'PAC CLI no devolvio agentes. Verifica tu perfil con: pac auth list (si no hay perfil, crealo como indica el README y vuelve a correr).' } } else { $modulos['pac'] = 'no instalado' Show-Advertencia 'PAC CLI no encontrado: no se inventariaran agentes. Instalalo con: dotnet tool install --global Microsoft.PowerApps.CLI.Tool' } # --- 10. Licencias (Graph) Write-Host 'Leyendo licencias...' -ForegroundColor Cyan $licencias = @() if ($conGraph -and $graphLic) { try { $skus = @(Get-MgSubscribedSku -All | Where-Object { $_.SkuPartNumber -match '(?i)POWER|FLOW|COPILOT|CCIBOT|PBI' }) foreach ($s in $skus) { $activos = $null if ($s.SkuPartNumber -match '(?i)PER_USER|PREMIUM' -and $s.ConsumedUnits -le 5000) { try { $limite = (Get-Date).AddDays(-30) $usr = @(Get-MgUser -All -Filter "assignedLicenses/any(x:x/skuId eq $($s.SkuId))" -Property 'Id,SignInActivity' -ConsistencyLevel eventual -CountVariable cnt) $activos = @($usr | Where-Object { $_.SignInActivity -and $_.SignInActivity.LastSignInDateTime -and $_.SignInActivity.LastSignInDateTime -ge $limite }).Count } catch { $activos = $null } } $licencias += , ([ordered]@{ sku = [string]$s.SkuPartNumber; nombre = [string]$s.SkuPartNumber compradas = [int]$s.PrepaidUnits.Enabled; asignadas = [int]$s.ConsumedUnits usuarios_activos_30d = $activos }) } $alcance.licencias = $true if (@($licencias | Where-Object { $null -eq $_.usuarios_activos_30d }).Count -gt 0) { Show-Advertencia 'Uso de licencias por usuario no disponible para algunos SKU (requiere Microsoft Entra ID P1 y permiso AuditLog.Read.All). Se mide por inicio de sesion, que es un estimado.' } } catch { Show-Advertencia ("No se pudieron leer las licencias: " + $_.Exception.Message) } } # --- 11. Licencias en Managed Environments (CSV opcional) $licME = $null if ($ReporteLicenciasME) { try { $licME = Read-ReporteLicenciasME -Ruta $ReporteLicenciasME -Sal $sal $alcance.licencias_me = $true } catch { Show-Advertencia ("No se pudo leer el CSV de licencias: " + $_.Exception.Message) } } if ($conGraph) { try { Disconnect-MgGraph | Out-Null } catch { } } # --- 12. Ensamblado if (-not $tenantId) { $tenantId = ($dominios.Keys | Sort-Object) -join '|' } $escaneo = [ordered]@{ meta = [ordered]@{ schema_version = '1.0' scanner_version = $script:Version generado_en = ConvertTo-IsoUtc (Get-Date) tenant_id_hash = Get-HashAnonimo -Valor $tenantId -Sal $sal ps_version = $PSVersionTable.PSVersion.ToString() modulos = $modulos alcance = $alcance dominios = [string[]]@($dominios.Keys | Sort-Object) } entornos = [object[]]$entornos apps = [object[]]$apps flujos = [object[]]$flujos agentes = [object[]]$agentes dlp = [object[]]$dlp licencias = [object[]]$licencias conectores_custom = [object[]]$custom licencias_me = $licME } $json = ConvertTo-EscaneoJson -Escaneo $escaneo if (-not [System.IO.Directory]::Exists($Salida)) { [System.IO.Directory]::CreateDirectory($Salida) | Out-Null } $ruta = Join-Path (Resolve-Path -LiteralPath $Salida).Path ((Get-Date).ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) + '.radar.json') Write-ArchivoUtf8SinBom -Ruta $ruta -Texto $json # --- 13. Resumen $tam = [math]::Round((Get-Item -LiteralPath $ruta).Length / 1KB, 1) Write-Host '' Write-Host 'Escaneo terminado' -ForegroundColor Green Write-Host (" Entornos: {0} | Apps: {1} | Flujos: {2} | Agentes: {3} | Politicas DLP: {4} | Conectores personalizados: {5}" -f $entornos.Count, $apps.Count, $flujos.Count, $agentes.Count, $dlp.Count, $custom.Count) $parciales = @($alcance.Keys | Where-Object { -not $alcance[$_] }) if ($parciales.Count -gt 0) { Write-Host (" Alcance parcial, sin datos de: " + ($parciales -join ', ')) -ForegroundColor Yellow } Write-Host '' Write-Host 'Que contiene el archivo:' -ForegroundColor Cyan Write-Host ' - Conteos y configuracion de entornos, apps, flujos, agentes, DLP, licencias (ids tecnicos, tipos, fechas).' Write-Host ' - Usuarios, correos y nombres de entornos/politicas como hash de 16 caracteres (la sal no se guarda).' Write-Host (' - Dominios de correo: ' + (($dominios.Keys | Sort-Object) -join ', ')) Write-Host 'Que NO contiene: contrasenas, tokens, contenido de apps/flujos, datos de Dataverse, correos ni nombres reales.' Write-Host '' Write-Host ("Archivo: {0} ({1} KB)" -f $ruta, $tam) -ForegroundColor Green Write-Host 'Antes de subirlo, abrelo con un editor de texto y revisa su contenido. Lo subes tu; este script no envia nada.' -ForegroundColor Yellow Write-Host ("Duracion: {0:N0} s" -f ((Get-Date) - $inicio).TotalSeconds) -ForegroundColor DarkGray } if (-not $SoloFunciones) { Invoke-Principal }