Public/Drift/Test-RecentWorkflowAdd.ps1

function Test-RecentWorkflowAdd {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Owner,

        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Repo,

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

        [string]$BaselinePath
    )

    $target = "$Owner/$Repo"
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    try {
        $workflowFiles = @(Get-WorkflowFile -Owner $Owner -Repo $Repo -Token $Token)
    }
    catch {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentWorkflowAdd' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $target `
            -Detail "Failed to collect workflow file inventory: $($_.Exception.Message)" `
            -Remediation 'Verify repository access and rerun.' `
            -Target $target `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    $currentPaths = @($workflowFiles | ForEach-Object { [string]$_.Path } | Sort-Object -Unique)
    $currentSnapshot = [PSCustomObject]@{
        Paths = $currentPaths
        StateSnapshot = $currentPaths
    }

    if (-not $BaselinePath) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentWorkflowAdd' `
            -Status 'Info' `
            -Severity 'Info' `
            -Resource $target `
            -Detail 'Baseline not provided. Captured current workflow inventory for next drift run.' `
            -Remediation 'Provide -BaselinePath from a previous scan to detect newly added workflow files.' `
            -Target $target `
            -Evidence @{
                Source = 'baseline-diff'
                To = $currentSnapshot
                Fidelity = 'No previous snapshot available; this run establishes baseline state.'
                StateSnapshot = $currentPaths
            } `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    try {
        $comparison = Compare-FylgyrBaseline -BaselinePath $BaselinePath -CheckName 'RecentWorkflowAdd' -Resource $target -CurrentSnapshot $currentSnapshot
    }
    catch {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentWorkflowAdd' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $target `
            -Detail "Failed to compare workflow inventory with baseline: $($_.Exception.Message)" `
            -Remediation 'Provide a valid JSON baseline generated by a previous Invoke-Fylgyr scan.' `
            -Target $target `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    $previousPaths = @()
    if ($comparison.HasBaseline -and $comparison.BaselineSnapshot) {
        if ($comparison.BaselineSnapshot.PSObject.Properties['Paths']) {
            $previousPaths = @($comparison.BaselineSnapshot.Paths)
        }
        elseif ($comparison.BaselineSnapshot -is [System.Array]) {
            $previousPaths = @($comparison.BaselineSnapshot)
        }
    }

    $previousSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($path in $previousPaths) {
        if (-not [string]::IsNullOrWhiteSpace($path)) {
            $previousSet.Add([string]$path) | Out-Null
        }
    }

    $newPaths = [System.Collections.Generic.List[string]]::new()
    foreach ($path in $currentPaths) {
        if (-not $previousSet.Contains($path)) {
            $newPaths.Add($path)
        }
    }

    if ($newPaths.Count -eq 0) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentWorkflowAdd' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $target `
            -Detail 'No new workflow files detected compared to baseline.' `
            -Remediation 'No action needed.' `
            -Target $target `
            -Evidence @{
                Source = 'baseline-diff'
                From = $comparison.BaselineSnapshot
                To = $currentSnapshot
                StateSnapshot = $currentPaths
            } `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    foreach ($newPath in $newPaths) {
        $file = $workflowFiles | Where-Object { $_.Path -eq $newPath } | Select-Object -First 1
        $content = if ($file) { [string]$file.Content } else { '' }
        $isHighRisk = $content -match '(?m)pull_request_target' -or $content -match '(?im)runs-on:\s*\[?\s*self-hosted'
        $severity = if ($isHighRisk) { 'High' } else { 'Medium' }
        $detailSuffix = if ($isHighRisk) { ' The workflow includes high-risk execution primitives (self-hosted or pull_request_target).' } else { '' }

        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentWorkflowAdd' `
            -Status 'Drift' `
            -Severity $severity `
            -Resource $target `
            -Detail "New workflow file detected: '$newPath'.$detailSuffix" `
            -Remediation 'Review workflow provenance, pin all actions to SHAs, and validate trigger and runner trust boundaries before merge.' `
            -AttackMapping @('tj-actions-shai-hulud', 'shai-hulud-runner-backdoor') `
            -Target $target `
            -Evidence @{
                Source = 'baseline-diff'
                From = $comparison.BaselineSnapshot
                To = $currentSnapshot
                NewPath = $newPath
                Fidelity = 'Baseline diff has no actor attribution; rely on audit logs for identity context.'
                StateSnapshot = $currentPaths
            } `
            -Mode 'Drift'))
    }

    return $results.ToArray()
}