Msec.psm1

# Dot-source private helpers first, then public functions; export only public.
$private = @(Get-ChildItem -Path (Join-Path $PSScriptRoot 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue)
$public  = @(Get-ChildItem -Path (Join-Path $PSScriptRoot 'Public')  -Filter '*.ps1' -ErrorAction SilentlyContinue)

foreach ($function in @($private + $public)) {
    try {
        . $function.FullName
    }
    catch {
        Write-Error "Failed to import function $($function.FullName): $_"
    }
}

# Module root path - used by functions that need to find bundled assets such as
# the Scripts/Linux and Scripts/Windows folders.
$script:MsecModuleRoot = $PSScriptRoot

# Module-scoped session populated by Connect-Msec, used by all score functions.
# The private key NEVER leaves Key Vault - signing happens via Invoke-AzKeyVaultKeyOperation.
# Shape:
# @{
# TenantId = '<guid>'
# ClientId = '<guid>' # appId of the msec app registration
# KeyVaultName = '<name>'
# KeyName = '<name>' # same as the cert name when KV created them together
# ThumbprintBytes = [byte[]] # SHA-1 thumbprint, used for the JWT x5t header
# Endpoints = [pscustomobject] # cloud endpoints from Get-MsecEnvironment
# # (AadAuthority, GraphResource, DefenderResource,
# # KeyVaultResource/DnsSuffix, ArmResource)
# Tokens = @{
# '<resource>' = @{ Token = '<jwt>'; ExpiresOn = [DateTimeOffset] }
# }
# }
$script:MsecSession = $null

# Global Administrator's roleTemplateId - the same GUID in every tenant and every
# cloud. Named here because the role's DISPLAY name is not dependable: Graph returns
# it as the legacy 'Company Administrator' on many tenants, and a tenant may rename it
# outright, so any code comparing role names would silently report zero Global Admins.
# Anything asking "is this the Global Administrator role" compares against this.
$script:MsecGlobalAdministratorTemplateId = '62e90394-69f5-4237-9190-012177145e10'

# Table views for the types with COLLECTION columns, which a DefaultDisplayPropertySet
# cannot render properly - it chooses the columns but not their formatting, so a string[]
# comes out as '{a, b}'. See the header of Msec.format.ps1xml for why the data stays an
# array and only the display is flattened.
#
# Loaded here rather than declared as FormatsToProcess in Msec.psd1 because the test suite
# imports Msec.psm1 DIRECTLY - a manifest key would be skipped on that path, and the views
# would silently not apply in exactly the place they are verified. One mechanism, one code
# path, works for both import styles.
$msecFormatFile = Join-Path $PSScriptRoot 'Msec.format.ps1xml'
if (Test-Path -LiteralPath $msecFormatFile) {
    # -PrependPath so these win over anything already registered for the same type names,
    # which matters on a re-import during development.
    Update-FormatData -PrependPath $msecFormatFile
}

# Default display property sets for msec types. Each Get-Msec* row that embeds a
# `Raw` (or otherwise heavy nested) property gets a PowerShell type name so
# Format-Table only shows the curated columns by default. The Raw column is still
# fully accessible via $row.Raw or Format-List - this only affects the *default*
# table layout. -Force is required because Pester / repeated imports re-register.
Update-TypeData -TypeName 'MsecEntraConditionalAccessPolicy' `
    -DefaultDisplayPropertySet 'DisplayName', 'State', 'Requires', 'IncludedGroups' `
    -Force

Update-TypeData -TypeName 'MsecEntraTenantSecuritySetting' `
    -DefaultDisplayPropertySet 'SecurityDefaultsEnabled', 'ConditionalAccessAvailable', 'GlobalAdministratorCount', 'EntraIdPremium' `
    -Force

Update-TypeData -TypeName 'MsecEntraMfaRegistration' `
    -DefaultDisplayPropertySet 'UserPrincipalName', 'IsAdmin', 'IsMfaCapable', 'DefaultMfaMethod' `
    -Force

Update-TypeData -TypeName 'MsecEntraMfaEvidence' `
    -DefaultDisplayPropertySet 'UserPrincipalName', 'EvidenceStatus', 'IsMfaCapable', 'DefaultMfaMethod', 'IsAdmin' `
    -Force

Update-TypeData -TypeName 'MsecEntraLicense' `
    -DefaultDisplayPropertySet 'SkuPartNumber', 'Enabled', 'Assigned', 'CapabilityStatus' `
    -Force

# The holder leads, because that is who an access review is about; the assignee
# trails, so a role inherited through a group shows the group it came from.
Update-TypeData -TypeName 'MsecEntraRoleHolder' `
    -DefaultDisplayPropertySet 'EffectiveName', 'EffectiveType', 'RoleName', 'AssignmentType', 'PrincipalName', 'PrincipalType' `
    -Force

# Two views for one row shape. Direction is deliberately out of both: it repeats
# what the caller already asked for, and only matters when rows from both
# directions are mixed. Convert-MsecEntraSid -Resolve pushes the ...Resolved type
# name in front so the resolved columns show up without a Format-Table; the plain
# name stays on the row either way, so a type check matches both.
Update-TypeData -TypeName 'MsecEntraSid' `
    -DefaultDisplayPropertySet 'Sid', 'ObjectId' `
    -Force

Update-TypeData -TypeName 'MsecEntraSidResolved' `
    -DefaultDisplayPropertySet 'Sid', 'ObjectId', 'DisplayName', 'ObjectType' `
    -Force

# NB: Msec.format.ps1xml defines the TABLE view for this type, and wins for Format-Table.
# This set still governs Format-List and Select-Object, keeping Raw and AssignmentDetail
# out of a list view - so both are needed, and both list the same columns on purpose.
#
# Output last: a script result can be long or multi-line, so it truncates gracefully at
# the end of the row instead of pushing the identifying columns off the terminal.
Update-TypeData -TypeName 'MsecIntuneScriptResult' `
    -DefaultDisplayPropertySet 'ScriptName', 'Source', 'DeviceName', 'State', 'Output' `
    -Force

# AssignmentType + AssignmentGroup rather than AssignmentCount: the count cannot tell 'All
# Users plus an exclusion group' from 'two unrelated groups', and which of those it is
# decides whether a row needs looking at. ExclusionGroup shows up in AssignmentType, so a
# carve-out is still visible here; AssignmentExcludedGroup names it. The count stays on the
# object, just out of the default view.
Update-TypeData -TypeName 'MsecIntuneConfigurationProfile' `
    -DefaultDisplayPropertySet 'DisplayName', 'Source', 'Platform', 'AssignmentType', 'AssignmentGroup', 'Status' `
    -Force

Update-TypeData -TypeName 'MsecAdoServiceConnection' `
    -DefaultDisplayPropertySet 'Name', 'Type', 'AuthScheme', 'IsShared' `
    -Force

# How long a cached Resource Graph result is reused before Search-MsecAzureResourceGraph goes
# back to Azure. Deliberately short. Resource inventory changes on the timescale of deployments,
# so a few minutes buys most of the benefit across a working session - while an hour is long
# enough that a vault opened to the internet this morning could still read as sealed, and these
# queries exist to catch exactly that. -NoCache forces a live query when you need one.
$script:MsecGraphCacheMaxAge = [timespan]::FromMinutes(15)

# Cache-backed -SubscriptionId completion, shared by every command that takes one.
#
# Registered here rather than as a per-parameter [ArgumentCompleter()] attribute because four
# commands need the identical completer and an attribute cannot be shared between them. The
# per-command completers (-ResourceType, -Name, -Subject, -WorkspaceName) stay as attributes:
# they belong to one command each, and living next to the parameter is more discoverable.
#
# Reads the local cache and NEVER calls Azure. A completer that queries ARM blocks the prompt on
# every Tab, and when ARM is unhealthy it does not fail fast, it hangs. The cache is refreshed by
# any estate-wide call, which already enumerates subscriptions, so it stays warm for free.
#
# Deliberately not Get-AzContext -ListAvailable, which is local and instant but answers a
# different question - see Get-MsecSubscriptionList for why that list is not the same set.
$msecSubscriptionCompleter = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    try {
        $module = Get-Module Msec
        if (-not $module) { return }
        # Read-MsecCache is private, and completers run outside module scope; invoking the
        # scriptblock against the module object runs it where private functions resolve.
        $subs = @(& $module { Read-MsecCache -Name 'subscriptions' })
        $word = ([string]$wordToComplete).Trim("'`"")

        $subs | Where-Object { $_.Name -like "$word*" -or $_.Id -like "$word*" } | Sort-Object Name |
            ForEach-Object {
                # -Subscription takes a name; -SubscriptionId (the older cmdlets, and the alias)
                # takes a GUID. Same cache, same list, different thing inserted.
                #
                # A name shared by several subscriptions is completed as the ID instead: the name
                # would bind and then fail as ambiguous, so completing it would be handing over a
                # value known not to work. This estate has three called 'Cloud Subscription'.
                $duplicated = @($subs | Where-Object Name -eq $_.Name).Count -gt 1
                $insert = if ($parameterName -eq 'Subscription' -and -not $duplicated) { $_.Name } else { $_.Id }
                if ($insert -match "[\s']") { $insert = "'" + ($insert -replace "'", "''") + "'" }

                [System.Management.Automation.CompletionResult]::new(
                    $insert, "$($_.Name) $($_.Id)", 'ParameterValue',
                    "$($_.Name) - $($_.Id) (tenant $($_.TenantId))")
            }
    }
    catch {
        # A completer must never throw or the prompt breaks.
    }
}

Register-ArgumentCompleter -ParameterName 'Subscription' -ScriptBlock $msecSubscriptionCompleter -CommandName @(
    'Search-MsecAzureResourceGraph'
)

# The two older cmdlets still take a GUID: Invoke-MsecAzureVMScript's -SubscriptionId is not a
# filter at all, it is pipeline data carrying the VM's subscription from Search-MsecAzureResource-
# Graph rows, so renaming it would break that binding.
Register-ArgumentCompleter -ParameterName 'SubscriptionId' -ScriptBlock $msecSubscriptionCompleter -CommandName @(
    'Get-MsecAzureSecureScore',
    'Invoke-MsecAzureVMScript'
)

Export-ModuleMember -Function $public.BaseName