Orchestrator/Export-AssessmentBaseline.ps1

function Export-AssessmentBaseline {
    <#
    .SYNOPSIS
        Saves a named baseline snapshot of all security-config collector results.
    .DESCRIPTION
        Reads all security-config CSVs (those containing CheckId and Status columns)
        from the current assessment folder and serialises them to JSON in a labelled
        baseline directory under <OutputFolder>/Baselines/<Label>_<TenantId>/.
        A metadata file records the label, tenant, version, sections run, and
        timestamp so that Compare-AssessmentBaseline can validate compatibility.
    .PARAMETER AssessmentFolder
        Path to the completed assessment output folder.
    .PARAMETER OutputFolder
        Root output folder (parent of Baselines/). Typically the -OutputFolder
        value passed to Invoke-M365Assessment.
    .PARAMETER Label
        Human-readable baseline label (e.g. 'Q1-2026'). Used as the folder name
        prefix and referenced with -CompareBaseline on future runs.
    .PARAMETER TenantId
        Tenant identifier passed in (GUID, vanity domain, or .onmicrosoft.com).
        Recorded in the manifest as the user-facing label. Falls back to the
        folder-key suffix when TenantGuid is not supplied (legacy callers).
    .PARAMETER TenantGuid
        Canonical tenant GUID (from Get-MgContext.TenantId via
        Resolve-TenantIdentity). When supplied, the baseline folder is named
        '<Label>_<TenantGuid>' so the same tenant referenced multiple ways
        produces a single folder (C1 #780). Friendly TenantId is preserved
        in the manifest for display.
    .PARAMETER DisplayName
        Tenant display name (Get-MgOrganization.DisplayName). Manifest only.
    .PARAMETER PrimaryDomain
        Primary verified domain (Get-MgOrganization.VerifiedDomains.IsDefault).
        Manifest only.
    .PARAMETER Environment
        Cloud environment string (commercial / gcc / gcchigh / dod). Manifest only.
    .PARAMETER Sections
        Array of section names that were assessed (recorded in metadata).
    .PARAMETER Version
        Assessment module version string (e.g. '1.15.0') recorded in metadata.
    .PARAMETER RegistryVersion
        Registry data version string (from controls/registry.json dataVersion)
        recorded in metadata to enable version-aware drift comparison.
    .EXAMPLE
        Export-AssessmentBaseline -AssessmentFolder $assessmentFolder `
            -OutputFolder '.\M365-Assessment' -Label 'Q1-2026' -TenantId 'contoso.com' `
            -TenantGuid '00000000-0000-0000-0000-000000000000'
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$AssessmentFolder,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputFolder,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Label,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter()]
        [string]$TenantGuid = '',

        [Parameter()]
        [string]$DisplayName = '',

        [Parameter()]
        [string]$PrimaryDomain = '',

        [Parameter()]
        [string]$Environment = '',

        [Parameter()]
        [string[]]$Sections = @(),

        [Parameter()]
        [string]$Version = '',

        [Parameter()]
        [string]$RegistryVersion = ''
    )

    # Sanitise label for use as a folder name
    $safeLabel = $Label -replace '[^\w\-]', '_'

    # C1 #780: prefer the canonical GUID as the folder-key suffix. When the
    # caller hasn't resolved one (legacy callers, AD-only runs without Graph),
    # fall back to the user-supplied TenantId so behavior matches pre-v2.9.0.
    $folderSuffix = if ($TenantGuid) {
        $TenantGuid -replace '[^\w\-]', ''
    } else {
        $TenantId -replace '[^\w\.\-]', '_'
    }
    $baselineDir = Join-Path -Path $OutputFolder -ChildPath "Baselines\${safeLabel}_${folderSuffix}"

    if (-not (Test-Path -Path $baselineDir -PathType Container)) {
        $null = New-Item -Path $baselineDir -ItemType Directory -Force
    }

    # Copy each security-config CSV as JSON (identified by having CheckId + Status columns)
    $csvFiles = Get-ChildItem -Path $AssessmentFolder -Filter '*.csv' -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -notlike '_*' }

    $saved = 0
    $checkCount = 0
    foreach ($csvFile in $csvFiles) {
        try {
            $rows = Import-Csv -Path $csvFile.FullName -ErrorAction Stop
            if (-not $rows) { continue }
            $firstRow = $rows | Select-Object -First 1
            $props = $firstRow.PSObject.Properties.Name
            # Only baseline security-config tables (must have both CheckId and Status)
            if ('CheckId' -notin $props -or 'Status' -notin $props) { continue }

            $jsonName = [System.IO.Path]::GetFileNameWithoutExtension($csvFile.Name) + '.json'
            $jsonPath = Join-Path -Path $baselineDir -ChildPath $jsonName
            $rows | ConvertTo-Json -Depth 5 | Set-Content -Path $jsonPath -Encoding UTF8
            $checkCount += @($rows).Count
            $saved++
        }
        catch {
            Write-Warning "Baseline: skipped '$($csvFile.Name)': $_"
        }
    }

    # Write manifest after CSV scan (includes accurate CheckCount).
    # C1 #780: enriched identity fields (TenantGuid + DisplayName + PrimaryDomain
    # + Environment) live alongside the legacy TenantId. Older readers ignore
    # the new fields; new readers use TenantGuid as the canonical key.
    $manifest = [PSCustomObject]@{
        Label             = $Label
        SavedAt           = (Get-Date -Format 'o')
        TenantId          = $TenantId
        TenantGuid        = $TenantGuid
        DisplayName       = $DisplayName
        PrimaryDomain     = $PrimaryDomain
        Environment       = $Environment
        AssessmentVersion = $Version
        RegistryVersion   = $RegistryVersion
        CheckCount        = $checkCount
        Sections          = $Sections
    }
    $manifestPath = Join-Path -Path $baselineDir -ChildPath 'manifest.json'
    $manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8

    Write-Verbose "Baseline '$Label' saved to '$baselineDir' ($saved collector files, $checkCount checks)"
    return $baselineDir
}