Microsoft-Extractor-Suite.psm1

param (
    [switch]$NoWelcome = $false
)

# Set supported TLS methods
[Net.ServicePointManager]::SecurityProtocol = "Tls12, Tls13"

$manifest = Import-PowerShellDataFile "$PSScriptRoot\Microsoft-Extractor-Suite.psd1"
$version = $manifest.ModuleVersion
$host.ui.RawUI.WindowTitle = "Microsoft-Extractor-Suite $version"

if (-not $NoWelcome) {
    $logo=@"
 +-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+
 |M|i|c|r|o|s|o|f|t| |E|x|t|r|a|c|t|o|r| |S|u|i|t|e|
 +-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+
Copyright 2025 Invictus Incident Response
Created by Joey Rentenaar & Korstiaan Stam
"@


    Write-Host $logo -ForegroundColor Yellow
}

$outputDir = "Output"
if (!(test-path $outputDir)) {
    New-Item -ItemType Directory -Force -Name $Outputdir > $null
}

$retryCount = 0 
    
Function StartDate {
    param([switch]$Quiet,
          [int]$DefaultOffset = -90)

    if (($startDate -eq "") -Or ($null -eq $startDate)) {
        $script:StartDate = [datetime]::Now.ToUniversalTime().AddDays($DefaultOffset)
        if (-not $Quiet) {
            Write-LogFile -Message "[INFO] No start date provided by user setting the start date to: $($script:StartDate.ToString("yyyy-MM-ddTHH:mm:ssK"))" -Color "Yellow"
        }
    }
    else {
        $script:startDate = [datetime]::Parse($startDate).ToUniversalTime()
        if (!$script:startDate -and -not $Quiet) { 
            Write-LogFile -Message "[WARNING] Not A valid start date and time, make sure to use YYYY-MM-DD" -Color "Red"
        } 
    }
}

Function StartDateUAL {
    param([switch]$Quiet)

    StartDate -Quiet:$Quiet -DefaultOffset:-180
}

Function StartDateAz {
    param([switch]$Quiet)
    
    StartDate -Quiet:$Quiet -DefaultOffset:-30
}

function EndDate {
    param([switch]$Quiet)
    
    if (($endDate -eq "") -Or ($null -eq $endDate)) {
        $script:EndDate = [datetime]::Now.ToUniversalTime()
        if (-not $Quiet) {
            Write-LogFile -Message "[INFO] No end date provided by user setting the end date to: $($script:EndDate.ToString("yyyy-MM-ddTHH:mm:ssK"))" -Color "Yellow"
        }
    }
    else {
        $script:endDate = [datetime]::Parse($endDate).ToUniversalTime()
        if (!$endDate -and -not $Quiet) { 
            Write-LogFile -Message "[WARNING] Not A valid end date and time, make sure to use YYYY-MM-DD" -Color "Red"
        } 
    }
}

[Flags()]
enum LogLevel {
    None     = 0
    Minimal  = 1
    Standard = 2
    Debug    = 3
}

$script:LogLevel = [LogLevel]::Standard

function Set-LogLevel {
    param (
        [LogLevel]$Level
    )
    $script:LogLevel = $Level
}


$logFile = "Output\LogFile.txt"
function Write-LogFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Message,
        [string]$Color,
        [switch]$NoNewLine,
        [LogLevel]$Level = [LogLevel]::Standard
    )

    if ($Level -gt $script:LogLevel) {
        return
    }

    if ($script:LogLevel -eq [LogLevel]::None) {
        return
    }

    $outputDir = "Output"
    if (!(test-path $outputDir)) {
        New-Item -ItemType Directory -Force -Name $Outputdir > $null
    }

    if(!$color -and $Level -eq [LogLevel]::Debug) {
        $color = "Yellow"
    }

    switch ($color) {
        "Yellow" { [Console]::ForegroundColor = [ConsoleColor]::Yellow }
        "Red"      { [Console]::ForegroundColor = [ConsoleColor]::Red }
        "Green"  { [Console]::ForegroundColor = [ConsoleColor]::Green }
        "Cyan"   { [Console]::ForegroundColor = [ConsoleColor]::Cyan }
        "White"  { [Console]::ForegroundColor = [ConsoleColor]::White }
        default  { [Console]::ResetColor() }
    }

    $logMessage = if (!$NoTimestamp) {
        "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'): $Message"
    } else {
        $Message
    }

    if ($NoNewLine) {
        [Console]::Write($Message)
    } else {
        [Console]::WriteLine($Message)
    }

    [Console]::ResetColor()
    $logMessage | Out-File -FilePath $LogFile -Append
}

function versionCheck{
    $moduleName = "Microsoft-Extractor-Suite"
    $currentVersionString = $version

    $currentVersion = [Version]$currentVersionString
    $latestVersionString = (Find-Module -Name $moduleName).Version.ToString()
    $latestVersion = [Version]$latestVersionString


    $latestVersion = (Find-Module -Name $moduleName).Version.ToString()

    if ($currentVersion -lt $latestVersion) {
        write-LogFile -Message "`n[INFO] You are running an outdated version ($currentVersion) of $moduleName. The latest version is ($latestVersion), please update to the latest version." -Color "Yellow"
    }
}

function Get-GraphAuthType {
    param (
        [string[]]$RequiredScopes
    )

    $context = Get-MgContext
    if (-not $context) {
        $authType = "none"
        $scopes = @()
    } else {
        $authType = $context | Select-Object -ExpandProperty AuthType
        $scopes = $context | Select-Object -ExpandProperty Scopes
    }

    $missingScopes = @()
    foreach ($requiredScope in $RequiredScopes) {
        if (-not ($scopes -contains $requiredScope)) {
            $missingScopes += $requiredScope
        }
    }

    $joinedScopes = $RequiredScopes -join ","
    switch ($authType) {
        "delegated" {
            if ($RequiredScopes -contains "Mail.ReadWrite") {
                Write-LogFile -Message "[WARNING] 'Mail.ReadWrite' is being requested under a delegated authentication type. 'Mail.ReadWrite' permissions only work when authenticating with an application." -Color "Yellow"
            }
            elseif ($missingScopes.Count -gt 0) {
                foreach ($missingScope in $missingScopes) {
                    Write-LogFile -Message "[INFO] Missing Graph scope detected: $missingScope" -Color "Yellow"
                }
                
                Write-LogFile -Message "[INFO] Attempting to re-authenticate with the appropriate scope(s): $joinedScopes" -Color "Green"
                Connect-MgGraph -NoWelcome -Scopes $joinedScopes > $null
            }
        }
        "AppOnly" {
            if ($missingScopes.Count -gt 0) {
                foreach ($missingScope in $missingScopes) {
                    Write-LogFile -Message "[INFO] The connected application is missing Graph scope detected: $missingScope" -Color "Red"
                }
            }
        }
        "none" {
            if ($RequiredScopes -contains "Mail.ReadWrite") {
                Write-LogFile -Message "[WARNING] 'Mail.ReadWrite' is being requested under a delegated authentication type. 'Mail.ReadWrite' permissions only work when authenticating with an application." -Color "Yellow"
            }
            else {
                Write-LogFile -Message "[INFO] No active Connect-MgGraph session found. Attempting to connect with the appropriate scope(s): $joinedScopes" -Color "Green"
                Connect-MgGraph -NoWelcome -Scopes $joinedScopes
            }
        }
    }

    return @{
        AuthType = $authType
        Scopes = $scopes
        MissingScopes = $missingScopes
    }
}

function Init-Logging {
    Set-LogLevel -Level ([LogLevel]::$LogLevel)
    $isDebugEnabled = $script:LogLevel -eq [LogLevel]::Debug

    $script:scriptStartedAt = Get-Date

    if ($isDebugEnabled) {
        Write-LogFile -Message "[DEBUG] PowerShell Version: $($PSVersionTable.PSVersion)" -Level Debug
        Write-LogFile -Message "[DEBUG] Input parameters:" -Level Debug
        foreach ($param in $PSBoundParameters.GetEnumerator()) {
            Write-LogFile -Message "[DEBUG] $($param.Key): $($param.Value)" -Level Debug
        }

        $graphModule = Get-Module -Name Microsoft.Graph* -ErrorAction SilentlyContinue
        if ($graphModule) {
            Write-LogFile -Message "[DEBUG] Microsoft Graph Modules loaded:" -Level Debug
            foreach ($module in $graphModule) {
                Write-LogFile -Message "[DEBUG] - $($module.Name) v$($module.Version)" -Level Debug
            }
        } else {
            Write-LogFile -Message "[DEBUG] No Microsoft Graph modules loaded" -Level Debug
        }
    }
}

function Check-GraphContext {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [object]$RequiredScopes
    )

    $graphAuth = Get-GraphAuthType -RequiredScopes $RequiredScopes

    if ($isDebugEnabled) {
        Write-LogFile -Message "[DEBUG] Graph authentication completed" -Level Debug
        try {
            $context = Get-MgContext
            if ($context) {
                Write-LogFile -Message "[DEBUG] Graph context information:" -Level Debug
                Write-LogFile -Message "[DEBUG] Account: $($context.Account)" -Level Debug
                Write-LogFile -Message "[DEBUG] Environment: $($context.Environment)" -Level Debug
                Write-LogFile -Message "[DEBUG] TenantId: $($context.TenantId)" -Level Debug
                Write-LogFile -Message "[DEBUG] Scopes: $($context.Scopes -join ', ')" -Level Debug
            }
        } catch {
            Write-LogFile -Message "[DEBUG] Could not retrieve Graph context details" -Level Debug
        }
    }
}

function Init-OutputDir {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Component,
        [string]$SubComponent = "",
        [Parameter(Mandatory=$true)]
        [string]$FilePostfix,
        [string]$CustomOutputDir = ""
    )

    if ([string]::IsNullOrEmpty($CustomOutputDir) -and $script:CollectionOutputDir) {
        $CustomOutputDir = $script:CollectionOutputDir
    }

    $date = [datetime]::Now.ToString('yyyyMMdd')

    if ($CustomOutputDir) {
        # Use custom directory but add component structure
        $OutputDir = Join-Path $CustomOutputDir "$Component\$date"
        if ($SubComponent -ne "") {
            $OutputDir += "-$SubComponent"
        }
        
        if (!(Test-Path -Path $CustomOutputDir)) {
            Write-LogFile -Message "[ERROR] Custom base directory invalid: $CustomOutputDir" -Level Minimal -Color "Red"
            throw
        }
        
        if (!(Test-Path -Path $OutputDir)) {
            Write-LogFile -Message "[DEBUG] Creating custom output directory: $OutputDir" -Level Debug
            New-Item -ItemType Directory -Force -Path $OutputDir > $null
        }
    } else {
        # Use default directory structure
        $OutputDir = "Output\$Component\$($date)"
        if ($SubComponent -ne "") {
            $OutputDir += "-$SubComponent"
        }
        
        if (!(Test-Path $OutputDir)) {
            Write-LogFile -Message "[DEBUG] Creating output directory: $OutputDir" -Level Debug
            New-Item -ItemType Directory -Force -Path $OutputDir > $null
        }
    }

    Write-LogFile -Message "[DEBUG] Using output directory: $OutputDir" -Level Debug
    $filename = "$($date)-$FilePostfix.csv"
    $script:outputFile = Join-Path $OutputDir $filename
}

function Write-Summary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [object]$Summary,
        [string]$Title = "Summary",
        [switch]$SkipExportDetails
    )

    Write-LogFile -Message "`n=== $Title ===" -Color "Cyan" -Level Standard

    foreach ($param in $Summary.GetEnumerator()) {
        if ($param.value -is [hashtable] -or $param.value -is [System.Collections.Specialized.OrderedDictionary]) {
            Write-LogFile -Message "`n$($param.key):" -Level Standard
            foreach($subitem in $param.value.GetEnumerator()) {
                Write-LogFile -Message " $($subitem.key): $($subitem.value)" -Level Standard
            }
        } else {
            Write-LogFile -Message "$($param.key): $($param.value)" -Level Standard
        }
    }

    # Only show Export Details if not skipped and outputFile exists
    if (-not $SkipExportDetails -and $script:outputFile) {
        $ProcessingTime = (Get-Date) - $script:ScriptStartedAt
        Write-LogFile -Message "`nExport Details:" -Level Standard
        Write-LogFile -Message " Output File: $script:outputFile" -Level Standard
        Write-LogFile -Message " Processing Time: $($ProcessingTime.ToString('mm\:ss'))" -Color "Green" -Level Standard
    } elseif (-not $SkipExportDetails) {
        # If no outputFile but we want to show processing time
        $ProcessingTime = (Get-Date) - $script:ScriptStartedAt
        Write-LogFile -Message "`nProcessing Time: $($ProcessingTime.ToString('mm\:ss'))" -Color "Green" -Level Standard
    }
}

function Merge-OutputFiles {
    param (
        [Parameter(Mandatory)][string]$OutputDir,
        [Parameter(Mandatory)][string]$OutputType,
        [string]$MergedFileName,
        [switch]$SofElk
    )

    $outputDirMerged = Join-Path -Path $OutputDir -ChildPath "Merged"
    If (!(Test-Path $outputDirMerged)) {
        Write-LogFile -Message "[INFO] Creating the following directory: $outputDirMerged"
        New-Item -ItemType Directory -Force -Path $outputDirMerged > $null
    }

    $mergedPath = Join-Path -Path $outputDirMerged -ChildPath $MergedFileName
    
    switch ($OutputType) {
        'CSV' {
            Get-ChildItem $OutputDir -Filter *.csv | Select-Object -ExpandProperty FullName | Import-Csv | Export-Csv $mergedPath -NoTypeInformation -Append -Encoding UTF8
            Write-LogFile -Message "[INFO] CSV files merged into $mergedPath"
        }
        'SOF-ELK' {

            $jsonFiles = Get-ChildItem $OutputDir -Filter *.json | Sort-Object Name
            foreach ($file in $jsonFiles) {
                Write-LogFile -Message "[DEBUG] Processing file: $($file.Name)" -Level Debug
                $content = Get-Content -Path $file.FullName -Encoding UTF8
                
                # Filter out empty lines and add each line to merged file
                $content | Where-Object { $_.Trim() -ne "" } | ForEach-Object {
                    Add-Content -Path $mergedPath -Value $_ -Encoding UTF8
                }
            }
            Write-LogFile -Message "[INFO] SOF-ELK files merged into $mergedPath"
        }
        'JSON' {           
            "[" | Set-Content $mergedPath -Encoding UTF8

            $firstFile = $true
            Get-ChildItem $OutputDir -Filter *.json | ForEach-Object {
                $content = Get-Content -Path $_.FullName -Raw
                
                $content = $content.Trim()
                if ($content.StartsWith('[')) {
                    $content = $content.Substring(1)
                }
                if ($content.EndsWith(']')) {
                    $content = $content.Substring(0, $content.Length - 1)
                }
                $content = $content.Trim()

                if (-not $firstFile -and $content) {
                    Add-Content -Path $mergedPath -Value "," -Encoding UTF8 -NoNewline
                }

                if ($content) {
                    Add-Content -Path $mergedPath -Value $content -Encoding UTF8 -NoNewline
                    $firstFile = $false
                }
            }
            "]" | Add-Content $mergedPath -Encoding UTF8
            Write-LogFile -Message "[INFO] JSON files merged into $mergedPath"
            
        }
        'JSONL' {
            $jsonlFiles = Get-ChildItem -Path $OutputDir -Filter *.jsonl
            if ($jsonlFiles.Count -eq 0) {
                Write-LogFile -Message "[ERROR] No JSONL files found in the specified directory: $OutputDir" -Color Red
                return
            }

            $mergedContent = @()
            foreach ($file in $jsonlFiles) {
                $content = Get-Content -Path $file.FullName -Raw
                $mergedContent += $content.Trim()
            }

            Set-Content -Path $mergedPath -Value ($mergedContent -join "`n") -Encoding UTF8
            Write-LogFile -Message "[INFO] JSONL files merged into $mergedPath"
        }
        default {
            Write-LogFile -Message "[ERROR] Unsupported file type specified: $OutputType" -Color Red
        }
    }
}

versionCheck

Export-ModuleMember -Function * -Alias * -Variable * -Cmdlet *