Private/ConvertTo-FylgyrSarif.ps1

function ConvertTo-FylgyrSarif {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject[]]$Results
    )

    # SARIF results represent findings — filter out passes
    $findings = @($Results | Where-Object { $_.Status -ne 'Pass' })

    $severityToLevel = @{
        Critical = 'error'
        High     = 'error'
        Medium   = 'warning'
        Low      = 'note'
        Info     = 'note'
    }

    # GitHub treats results as security findings when security-severity is set (0.0-10.0)
    $severityToScore = @{
        Critical = '9.5'
        High     = '8.0'
        Medium   = '5.5'
        Low      = '2.0'
        Info     = '0.0'
    }

    $attacksPath = Join-Path -Path $PSScriptRoot -ChildPath '..' | Join-Path -ChildPath 'Data' | Join-Path -ChildPath 'attacks.json'
    $attackCatalog = @{}
    if (Test-Path $attacksPath) {
        $attacks = Get-Content -Path $attacksPath -Raw | ConvertFrom-Json
        foreach ($a in $attacks) {
            $attackCatalog[$a.id] = $a
        }
    }

    $rules = [System.Collections.Generic.Dictionary[string, PSCustomObject]]::new()
    $sarifResults = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($r in $findings) {
        $ruleId = "fylgyr/$($r.CheckName)"

        if (-not $rules.ContainsKey($ruleId)) {
            $helpText = $r.Remediation
            if ($r.AttackMapping.Count -gt 0) {
                $attackNames = $r.AttackMapping | ForEach-Object {
                    if ($attackCatalog.ContainsKey($_)) { $attackCatalog[$_].name } else { $_ }
                }
                $helpText += "`n`nRelated attacks: $($attackNames -join ', ')"
            }

            $ruleTags = [System.Collections.Generic.List[string]]::new()
            $ruleTags.Add('security')
            $ruleTags.Add('supply-chain')

            $rules[$ruleId] = [PSCustomObject]@{
                id                   = $ruleId
                name                 = $r.CheckName
                shortDescription     = [PSCustomObject]@{ text = $r.CheckName }
                fullDescription      = [PSCustomObject]@{ text = $r.Detail }
                helpUri              = 'https://github.com/pthoor/Fylgyr'
                help                 = [PSCustomObject]@{
                    text     = $helpText
                    markdown = $helpText
                }
                defaultConfiguration = [PSCustomObject]@{
                    level = $severityToLevel[$r.Severity]
                }
                properties           = [PSCustomObject]@{
                    tags                = $ruleTags.ToArray()
                    precision           = 'high'
                    'security-severity' = $severityToScore[$r.Severity]
                }
            }
        }

        # Determine whether Resource is a repo-level identifier or a file path.
        # Repo-level resources look like "owner/repo" or
        # "owner/repo (branch: main)". Everything else is treated as a file
        # path, with an optional trailing ":line" suffix parsed below.
        $isRepoLevelResource = $r.Resource -match '^[^/\s]+/[^/\s]+(?: \(branch: .+\))?$'
        $isFilePath = -not $isRepoLevelResource

        $sarifResult = [PSCustomObject]@{
            ruleId  = $ruleId
            level   = $severityToLevel[$r.Severity]
            message = [PSCustomObject]@{ text = $r.Detail }
        }

        if ($isFilePath) {
            $filePath = $r.Resource
            $startLine = 1
            if ($r.Resource -match '^(.+):(\d+)$') {
                $filePath = $Matches[1]
                $startLine = [int]$Matches[2]
            }
            $sarifResult | Add-Member -NotePropertyName 'locations' -NotePropertyValue @(
                [PSCustomObject]@{
                    physicalLocation = [PSCustomObject]@{
                        artifactLocation = [PSCustomObject]@{
                            uri       = $filePath
                            uriBaseId = '%SRCROOT%'
                        }
                        region = [PSCustomObject]@{
                            startLine = $startLine
                        }
                    }
                }
            )
        } else {
            # GitHub code scanning requires physicalLocation on every result.
            # Repo-level findings point to a sentinel path with the detail in the message.
            $sarifResult | Add-Member -NotePropertyName 'locations' -NotePropertyValue @(
                [PSCustomObject]@{
                    physicalLocation = [PSCustomObject]@{
                        artifactLocation = [PSCustomObject]@{
                            uri       = 'SECURITY.md'
                            uriBaseId = '%SRCROOT%'
                        }
                        region = [PSCustomObject]@{
                            startLine = 1
                        }
                    }
                    message = [PSCustomObject]@{
                        text = "Repository setting: $($r.Resource)"
                    }
                }
            )
        }

        # Generate a stable fingerprint from rule + resource + detail to prevent
        # duplicate alerts across runs (required by GitHub code scanning).
        $fingerprintInput = "$ruleId|$($r.Resource)|$($r.Detail)"
        $sha256 = [System.Security.Cryptography.SHA256]::Create()
        $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($fingerprintInput))
        $hashHex = ($hashBytes[0..7] | ForEach-Object { $_.ToString('x2') }) -join ''
        $sarifResult | Add-Member -NotePropertyName 'partialFingerprints' -NotePropertyValue ([PSCustomObject]@{
            primaryLocationLineHash = "${hashHex}:1"
        })

        if ($r.AttackMapping.Count -gt 0) {
            $tags = [System.Collections.Generic.List[string]]::new()
            foreach ($attackId in $r.AttackMapping) {
                $tags.Add("attack:$attackId")
            }
            $sarifResult | Add-Member -NotePropertyName 'properties' -NotePropertyValue ([PSCustomObject]@{
                tags = $tags.ToArray()
            })
        }

        $sarifResults.Add($sarifResult)
    }

    $module = Get-Module -Name Fylgyr -ErrorAction SilentlyContinue
    $versionStr = if ($module -and $module.Version) { $module.Version.ToString() } else { '0.1.0' }

    $sarif = [PSCustomObject]@{
        '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json'
        version = '2.1.0'
        runs = @(
            [PSCustomObject]@{
                tool = [PSCustomObject]@{
                    driver = [PSCustomObject]@{
                        name            = 'Fylgyr'
                        informationUri  = 'https://github.com/pthoor/Fylgyr'
                        semanticVersion = $versionStr
                        rules           = @($rules.Values)
                    }
                }
                results = $sarifResults.ToArray()
            }
        )
    }

    $sarif | ConvertTo-Json -Depth 20
}