modules/Invoke-Maester.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for Maester — Entra ID / identity security posture assessment.
.DESCRIPTION
    Installs/imports the Maester module if needed, verifies a Microsoft Graph
    connection exists, runs Invoke-Maester -PassThru -Quiet, and returns
    findings as PSObjects. Gracefully degrades if Maester is not available,
    Graph is not connected, or the assessment fails.
    Requires: Connect-MgGraph -Scopes (Get-MtGraphScope)
.EXAMPLE
    .\Invoke-Maester.ps1
#>

[CmdletBinding()]
param()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }
$envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
$errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param([string]$Text) return $Text }
}
if (-not (Get-Command Write-MissingToolNotice -ErrorAction SilentlyContinue)) {
    function Write-MissingToolNotice { param([string]$Tool, [string]$Message) Write-Warning $Message }
}
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) {
    function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } }
}
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [PSCustomObject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}

function ConvertTo-MaesterStringArray {
    param([object] $Value)
    $values = [System.Collections.Generic.List[string]]::new()
    foreach ($item in @($Value)) {
        if ($null -eq $item) { continue }
        if ($item -is [string]) {
            if (-not [string]::IsNullOrWhiteSpace($item)) { $values.Add($item.Trim()) | Out-Null }
            continue
        }
        if ($item -is [System.Collections.IDictionary]) {
            foreach ($candidate in 'Id', 'ID', 'id', 'AppId', 'appId', 'Name', 'name') {
                if ($item.Contains($candidate) -and -not [string]::IsNullOrWhiteSpace([string]$item[$candidate])) {
                    $values.Add(([string]$item[$candidate]).Trim()) | Out-Null
                    break
                }
            }
            continue
        }
        if ($item -is [System.Collections.IEnumerable] -and $item -isnot [string]) {
            foreach ($nested in @(ConvertTo-MaesterStringArray -Value $item)) {
                if (-not [string]::IsNullOrWhiteSpace($nested)) { $values.Add($nested) | Out-Null }
            }
            continue
        }
        $candidate = [string]$item
        if (-not [string]::IsNullOrWhiteSpace($candidate)) { $values.Add($candidate.Trim()) | Out-Null }
    }
    return @($values | Select-Object -Unique)
}

function Get-MaesterPropertyValue {
    param(
        [Parameter(Mandatory)]
        [object] $Object,
        [Parameter(Mandatory)]
        [string[]] $Candidates
    )
    foreach ($candidate in $Candidates) {
        if ($Object -is [System.Collections.IDictionary] -and $Object.Contains($candidate) -and $null -ne $Object[$candidate]) {
            return $Object[$candidate]
        }
        $property = $Object.PSObject.Properties[$candidate]
        if ($property -and $null -ne $property.Value) {
            return $property.Value
        }
    }
    return $null
}

function Get-MaesterToolVersion {
    $module = Get-Module -Name Maester -ListAvailable -ErrorAction SilentlyContinue |
        Sort-Object -Property Version -Descending |
        Select-Object -First 1
    if ($module -and $module.Version) { return [string]$module.Version }
    return ''
}

function Get-MaesterTestId {
    param([object] $Test)
    foreach ($candidate in 'TestId', 'Id', 'ID') {
        $value = Get-MaesterPropertyValue -Object $Test -Candidates @($candidate)
        if (-not [string]::IsNullOrWhiteSpace([string]$value)) { return [string]$value }
    }
    $name = [string](Get-MaesterPropertyValue -Object $Test -Candidates @('Name', 'ExpandedName'))
    if ([string]::IsNullOrWhiteSpace($name)) { return [guid]::NewGuid().ToString() }
    return ($name -replace '[^A-Za-z0-9._-]', '-').Trim('-')
}

function Get-MaesterTagMetadata {
    param([string[]] $Tags)
    $frameworks = [System.Collections.Generic.List[hashtable]]::new()
    $baselineTags = [System.Collections.Generic.List[string]]::new()
    $mitreTactics = [System.Collections.Generic.List[string]]::new()
    $mitreTechniques = [System.Collections.Generic.List[string]]::new()
    $seenFrameworks = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($tagRaw in @($Tags)) {
        if ([string]::IsNullOrWhiteSpace($tagRaw)) { continue }
        $tag = $tagRaw.Trim()

        if ($tag -match '^(?i)(?:mitre[-_:\/]?)?(TA\d{4})$') {
            $mitreTactics.Add($Matches[1].ToUpperInvariant()) | Out-Null
            continue
        }
        if ($tag -match '^(?i)(?:mitre[-_:\/]?)?(T\d{4}(?:\.\d{3})?)$') {
            $mitreTechniques.Add($Matches[1].ToUpperInvariant()) | Out-Null
            continue
        }

        $frameworkName = $null
        if ($tag -match '^(?i)CIS-MS365-[A-Za-z0-9.\-]+$') {
            $frameworkName = 'CIS Microsoft 365'
        } elseif ($tag -match '^(?i)NIST(?:-800-53)?-[A-Za-z0-9.\-]+$') {
            $frameworkName = 'NIST 800-53'
        } elseif ($tag -match '^(?i)EIDSCA-[A-Za-z0-9.\-]+$') {
            $frameworkName = 'EIDSCA'
        }

        if ($frameworkName) {
            $baselineTags.Add($tag) | Out-Null
            $key = "$frameworkName|$tag"
            if ($seenFrameworks.Add($key)) {
                $frameworks.Add([ordered]@{
                        kind      = $frameworkName
                        Name      = $frameworkName
                        controlId = $tag
                        Controls  = @($tag)
                    }) | Out-Null
            }
        }
    }

    [PSCustomObject]@{
        Frameworks      = @($frameworks)
        BaselineTags    = @($baselineTags | Select-Object -Unique)
        MitreTactics    = @($mitreTactics | Select-Object -Unique)
        MitreTechniques = @($mitreTechniques | Select-Object -Unique)
    }
}

function Get-MaesterRemediationSnippets {
    param([string] $Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return @() }
    $snippets = [System.Collections.Generic.List[hashtable]]::new()
    $matches = [regex]::Matches($Text, '(?ms)```(?<language>[^\r\n`]*)\r?\n(?<code>.*?)```')
    foreach ($match in $matches) {
        $code = [string]$match.Groups['code'].Value
        if ([string]::IsNullOrWhiteSpace($code)) { continue }
        $language = [string]$match.Groups['language'].Value
        if ([string]::IsNullOrWhiteSpace($language)) { $language = 'text' }
        $snippets.Add(@{
                language = $language.Trim().ToLowerInvariant()
                code     = $code.Trim()
            }) | Out-Null
    }
    if ($snippets.Count -eq 0) {
        $snippets.Add(@{ language = 'text'; code = $Text.Trim() }) | Out-Null
    }
    return @($snippets)
}

# Check Maester module is available (centralized Install-Prerequisites handles installation)
if (-not (Get-Module -ListAvailable -Name Maester)) {
    Write-MissingToolNotice -Tool 'maester' -Message "Maester module not found. Install with: Install-Module Maester -Scope CurrentUser"
    return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'maester'; Status = 'Skipped'; Message = 'Maester module not installed. Run: Install-Module Maester -Scope CurrentUser'; Findings = @(); Errors = @() }
}

Import-Module Maester -ErrorAction SilentlyContinue
if (-not (Get-Command Invoke-Maester -ErrorAction SilentlyContinue)) {
    Write-MissingToolNotice -Tool 'maester' -Message "Maester module loaded but Invoke-Maester not found. Returning empty result."
    return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'maester'; Status = 'Skipped'; Message = 'Invoke-Maester command not available'; Findings = @(); Errors = @() }
}

# Verify Microsoft Graph connection
if (-not (Get-Command Get-MgContext -ErrorAction SilentlyContinue)) {
    Write-MissingToolNotice -Tool 'maester' -Message "Microsoft Graph SDK command Get-MgContext not found. Install Microsoft.Graph and connect before using Maester."
    return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'maester'; Status = 'Skipped'; Message = 'Get-MgContext command not available. Install Microsoft.Graph and run Connect-MgGraph.'; Findings = @(); Errors = @() }
}

# Probe Microsoft Graph context (SilentlyContinue: probing for connection state, not an error)
$mgContext = Get-MgContext -ErrorAction SilentlyContinue
if (-not $mgContext) {
    Write-Warning "No Microsoft Graph connection found. Run 'Connect-MgGraph -Scopes (Get-MtGraphScope)' before using Maester. Returning empty result."
    return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'maester'; Status = 'Skipped'; Message = 'No Microsoft Graph connection. Run: Connect-MgGraph -Scopes (Get-MtGraphScope)'; Findings = @(); Errors = @() }
}

# Run Maester assessment — returns a Pester TestResultContainer
try {
    $container = Invoke-Maester -PassThru -Quiet -ErrorAction Stop
} catch {
    Write-Warning "Maester assessment failed: $(Remove-Credentials -Text ([string]$_)). Returning empty result."
    return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'maester'; Status = 'Failed'; Message = (Remove-Credentials -Text ([string]$_)); Findings = @(); Errors = @() }
}

if (-not $container -or -not $container.Result) {
    Write-Warning "Maester returned no test results."
    return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'maester'; Status = 'Failed'; Message = 'No test results returned'; Findings = @(); Errors = @() }
}

# Map Pester TestResult objects to flat findings
$findings = [System.Collections.Generic.List[PSCustomObject]]::new()
$toolVersion = Get-MaesterToolVersion

foreach ($test in $container.Result) {
    $testId = Get-MaesterTestId -Test $test
    $tags = ConvertTo-MaesterStringArray -Value (Get-MaesterPropertyValue -Object $test -Candidates @('Tag', 'Tags'))
    $tagMetadata = Get-MaesterTagMetadata -Tags $tags
    $deepLinkUrl = if (-not [string]::IsNullOrWhiteSpace($testId)) {
        "https://maester.dev/docs/tests/$testId"
    } else {
        ''
    }

    # Derive severity from tags using word boundaries so tags like
    # "criticality" or "highlight" don't bleed into Critical/High.
    $severity = 'Medium'
    if ($tags) {
        $tagStr = ($tags -join ' ').ToLowerInvariant()
        if     ($tagStr -match '\bcritical\b') { $severity = 'Critical' }
        elseif ($tagStr -match '\bhigh\b')     { $severity = 'High' }
        elseif ($tagStr -match '\blow\b')      { $severity = 'Low' }
        elseif ($tagStr -match '\b(info|informational)\b') { $severity = 'Info' }
    }

    # Map Result: Passed/Skipped/NotRun → compliant, Failed → non-compliant
    $compliant = $test.Result -ne 'Failed'

    # Extract detail from ErrorRecord if present
    $detail = ''
    $errorRecord = Get-MaesterPropertyValue -Object $test -Candidates @('ErrorRecord')
    if ($errorRecord) {
        $detail = ($errorRecord | ForEach-Object { $_.ToString() }) -join '; '
    } else {
        $detail = [string](Get-MaesterPropertyValue -Object $test -Candidates @('FailureMessage', 'ErrorMessage', 'Detail', 'ResultDetail'))
    }
    if ($null -eq $detail) { $detail = '' }

    # Extract category from parent Block name
    $category = 'Identity'
    $block = Get-MaesterPropertyValue -Object $test -Candidates @('Block')
    if ($block -and $block.PSObject.Properties['Name'] -and $block.Name) {
        $category = $block.Name
    }

    $learnMore = [string](Get-MaesterPropertyValue -Object $test -Candidates @('LearnMoreUrl', 'LearnMore', 'DocumentationUrl', 'DocsUrl', 'ReferenceUrl'))
    $remediation = [string](Get-MaesterPropertyValue -Object $test -Candidates @('HowToFix', 'Fix', 'Remediation', 'Recommendation'))
    if ($null -eq $remediation) { $remediation = '' }

    $evidenceUris = [System.Collections.Generic.List[string]]::new()
    foreach ($uri in ConvertTo-MaesterStringArray -Value (Get-MaesterPropertyValue -Object $test -Candidates @('EvidenceUris', 'EvidenceUri', 'EvidenceLinks', 'Evidence'))) {
        if ($uri -match '^(?i)https://') { $evidenceUris.Add($uri) | Out-Null }
    }
    foreach ($uri in ConvertTo-MaesterStringArray -Value (Get-MaesterPropertyValue -Object $test -Candidates @('SourceUri', 'SourceUrl', 'TestSourceUri', 'TestSourceUrl', 'Source'))) {
        if ($uri -match '^(?i)https://') { $evidenceUris.Add($uri) | Out-Null }
    }
    if (-not [string]::IsNullOrWhiteSpace($learnMore)) { $evidenceUris.Add($learnMore) | Out-Null }
    if (-not [string]::IsNullOrWhiteSpace($deepLinkUrl)) { $evidenceUris.Add($deepLinkUrl) | Out-Null }
    $evidenceUrisOut = @($evidenceUris | Select-Object -Unique)

    $entityRefs = [System.Collections.Generic.List[string]]::new()
    if ($mgContext.TenantId) { $entityRefs.Add([string]$mgContext.TenantId) | Out-Null }
    foreach ($sp in ConvertTo-MaesterStringArray -Value (Get-MaesterPropertyValue -Object $test -Candidates @('ServicePrincipalIds', 'ServicePrincipals', 'AppIds', 'ApplicationIds'))) {
        if ($sp -match '^[0-9a-fA-F-]{36}$' -or $sp -match '^(?i)(appid|objectid):[0-9a-fA-F-]{36}$') {
            $entityRefs.Add($sp) | Out-Null
        }
    }
    $scopeObject = Get-MaesterPropertyValue -Object $test -Candidates @('Scope', 'Context')
    if ($scopeObject) {
        foreach ($sp in ConvertTo-MaesterStringArray -Value (Get-MaesterPropertyValue -Object $scopeObject -Candidates @('ServicePrincipalIds', 'ServicePrincipals', 'AppIds', 'ApplicationIds'))) {
            if ($sp -match '^[0-9a-fA-F-]{36}$' -or $sp -match '^(?i)(appid|objectid):[0-9a-fA-F-]{36}$') {
                $entityRefs.Add($sp) | Out-Null
            }
        }
    }
    foreach ($tag in $tags) {
        if ($tag -match '^(?i)spn:(?<id>[0-9a-fA-F-]{36})$') {
            $entityRefs.Add($Matches['id']) | Out-Null
        }
    }
    $entityRefsOut = @($entityRefs | Select-Object -Unique)

    $findings.Add([PSCustomObject]@{
        Id                  = "maester/$testId"
        TestId              = $testId
        Category            = $category
        Title               = if ((Get-MaesterPropertyValue -Object $test -Candidates @('Name'))) { [string](Get-MaesterPropertyValue -Object $test -Candidates @('Name')) } else { 'Unknown' }
        Severity            = $severity
        Compliant           = $compliant
        Detail              = $detail
        Remediation         = $remediation
        ResourceId          = ''
        LearnMoreUrl        = $learnMore
        Frameworks          = @($tagMetadata.Frameworks)
        Pillar              = 'Security'
        BaselineTags        = @($tagMetadata.BaselineTags)
        DeepLinkUrl         = $deepLinkUrl
        EvidenceUris        = @($evidenceUrisOut)
        RemediationSnippets = @(Get-MaesterRemediationSnippets -Text $remediation)
        EntityRefs          = @($entityRefsOut)
        ToolVersion         = $toolVersion
        MitreTactics        = @($tagMetadata.MitreTactics)
        MitreTechniques     = @($tagMetadata.MitreTechniques)
        SchemaVersion       = '1.0'
    })
}

return [PSCustomObject]@{
    SchemaVersion = '1.0'
    Source        = 'maester'
    Status        = 'Success'
    Message       = ''
    TenantId      = [string]$mgContext.TenantId
    ToolVersion   = $toolVersion
    Findings      = @($findings)
    Errors        = @()
}