Public/Export-IntuneFindingSarif.ps1

function Export-IntuneFindingSarif {
    <#
    .EXTERNALHELP IntuneScriptLab-Help.xml
    .SYNOPSIS
        Writes IntuneScriptLab findings as a SARIF 2.1.0 log for code scanning.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowEmptyCollection()]
        [pscustomobject[]]$Finding,

        [Parameter(Mandatory)]
        [string]$Path,

        [string]$Root = $(if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path })
    )

    begin {
        $all = [System.Collections.Generic.List[object]]::new()
        $levels = @{ Error = 'error'; Warning = 'warning'; Information = 'note' }
        $module = $MyInvocation.MyCommand.Module
        $rootPath = (Resolve-Path -LiteralPath $Root -ErrorAction Stop).ProviderPath.TrimEnd('\', '/')
        # The context note and the pre-flight's policy checks have no rule function to read help from
        $fixedRules = @{
            IslAssumedContext     = @{
                Level   = 'note'
                Summary = 'The script type, context and architecture were assumed, not declared.'
            }
            IslDetectionRuleIssue = @{
                Level   = 'error'
                Summary = 'A Win32 detection rule the agent does not evaluate.'
            }
            IslAssignmentIssue    = @{
                Level   = 'warning'
                Summary = 'A Win32 app assignment that can never install.'
            }
            IslDetectOnly         = @{
                Level   = 'note'
                Summary = 'A remediation with no remediation script runs its detection alone.'
            }
        }
    }

    process {
        foreach ($item in $Finding) { if ($null -ne $item) { $all.Add($item) } }
    }

    end {
        function Get-RelativeUri {
            param([string]$FilePath)
            $relative = if ($FilePath.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) {
                $FilePath.Substring($rootPath.Length).TrimStart('\', '/')
            }
            else { $FilePath }
            $segments = $relative -split '[\\/]' | ForEach-Object { [System.Uri]::EscapeDataString($_) }
            $segments -join '/'
        }

        $ruleIndex = [ordered]@{}
        $rules = [System.Collections.Generic.List[object]]::new()
        foreach ($name in @($all | ForEach-Object { $_.RuleName } | Sort-Object -Unique)) {
            $summary = ''
            $description = ''
            $level = 'warning'
            if ($fixedRules.ContainsKey($name)) {
                $summary = $fixedRules[$name].Summary
                $level = $fixedRules[$name].Level
            }
            elseif (Get-Command -Name "Find-$name" -ErrorAction SilentlyContinue) {
                $help = Get-Help -Name "Find-$name" -ErrorAction SilentlyContinue
                $summary = "$($help.Synopsis)".Trim()
                $description = (@($help.Description | ForEach-Object { $_.Text }) -join ' ') -replace '\s+', ' '
            }
            if (-not $summary) { $summary = $name }
            $rule = [ordered]@{
                id               = $name
                name             = $name
                shortDescription = [ordered]@{ text = $summary }
            }
            if ($description) { $rule.fullDescription = [ordered]@{ text = $description.Trim() } }
            $rule.defaultConfiguration = [ordered]@{ level = $level }
            $ruleIndex[$name] = $rules.Count
            $rules.Add($rule)
        }

        $results = foreach ($item in $all) {
            $region = [ordered]@{ startLine = [Math]::Max(1, [int]$item.Line) }
            if ($item.Column -gt 0) { $region.startColumn = [int]$item.Column }
            if ($item.Text) { $region.snippet = [ordered]@{ text = "$($item.Text)" } }
            $result = [ordered]@{
                ruleId    = $item.RuleName
                ruleIndex = $ruleIndex[$item.RuleName]
                level     = $levels["$($item.Severity)"]
                message   = [ordered]@{ text = "$($item.Message)" }
                locations = @(
                    [ordered]@{
                        physicalLocation = [ordered]@{
                            artifactLocation = [ordered]@{
                                uri       = Get-RelativeUri -FilePath "$($item.ScriptPath)"
                                uriBaseId = '%SRCROOT%'
                            }
                            region           = $region
                        }
                    }
                )
                properties = [ordered]@{
                    scriptType = "$($item.ScriptType)"
                    evidence   = "$($item.Evidence)"
                }
            }
            if ($item.PSObject.Properties['Suppressed'] -and $item.Suppressed) {
                $result.suppressions = @([ordered]@{ kind = 'inSource' })
            }
            $result
        }

        $rootUri = ([System.Uri]::new($rootPath + [System.IO.Path]::DirectorySeparatorChar)).AbsoluteUri
        $driver = [ordered]@{
            name           = 'IntuneScriptLab'
            version        = "$($module.Version)"
            informationUri = "$($module.PrivateData.PSData.ProjectUri)"
            rules          = @($rules)
        }
        $log = [ordered]@{
            '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json'
            version   = '2.1.0'
            runs      = @(
                [ordered]@{
                    tool               = [ordered]@{ driver = $driver }
                    originalUriBaseIds = [ordered]@{ '%SRCROOT%' = [ordered]@{ uri = $rootUri } }
                    results            = @($results)
                }
            )
        }

        $folder = Split-Path -Path $Path -Parent
        if ($folder) { $null = New-Item -ItemType Directory -Path $folder -Force }
        $json = $log | ConvertTo-Json -Depth 12
        [System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false))
        Write-Verbose "Wrote $($all.Count) result(s) for $($rules.Count) rule(s) to $Path"
        Get-Item -LiteralPath $Path
    }
}