Public/Invoke-Fylgyr.ps1
|
function Invoke-Fylgyr { [CmdletBinding()] [OutputType([PSCustomObject[]], [string])] param( [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Owner, [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Repo, [ValidateSet('Object', 'JSON', 'SARIF', 'Console')] [string]$OutputFormat = 'Object', [string]$Token = $env:GITHUB_TOKEN ) begin { if (-not $Token) { throw 'GitHub token not provided. Use -Token or set $env:GITHUB_TOKEN.' } $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() $scannedTargets = [System.Collections.Generic.List[string]]::new() # Owner-level check caches. Reset every run so repeated Invoke-Fylgyr calls # inside the same session do not reuse stale data. # - FylgyrOwnerRunnerGroupsChecked: Test-RunnerHygiene consults this to skip the # `orgs/{Owner}/...` block on second and later repos in an org-wide scan. # - FylgyrOwnerAppSecurityResults: Test-GitHubAppSecurity results cached per owner # so we emit them exactly once per owner instead of once per repository. $script:FylgyrOwnerRunnerGroupsChecked = @{} $script:FylgyrOwnerAppSecurityResults = @{} $script:FylgyrOwnerAppSecurityEmitted = @{} } process { # If no Repo specified, enumerate all repos for the Owner (org-wide scan) if (-not $Repo) { $repos = [System.Collections.Generic.List[string]]::new() try { $orgRepos = Invoke-GitHubApi -Endpoint "orgs/$Owner/repos?per_page=100" -Token $Token -AllPages } catch { try { $orgRepos = Invoke-GitHubApi -Endpoint "users/$Owner/repos?per_page=100" -Token $Token -AllPages } catch { $allResults.Add((Format-FylgyrResult ` -CheckName 'OrgRepoList' ` -Status 'Error' ` -Severity 'Critical' ` -Resource $Owner ` -Detail "Failed to list repositories for '$Owner': $($_.Exception.Message)" ` -Remediation 'Verify the owner exists and the token has repo access.' ` -Target $Owner)) return } } foreach ($r in $orgRepos) { $repos.Add($r.name) } if ($repos.Count -eq 0) { $allResults.Add((Format-FylgyrResult ` -CheckName 'OrgRepoList' ` -Status 'Warning' ` -Severity 'Info' ` -Resource $Owner ` -Detail "No repositories found for '$Owner'." ` -Remediation 'Verify the owner name and token permissions.' ` -Target $Owner)) return } $repoTotal = $repos.Count for ($i = 0; $i -lt $repoTotal; $i++) { $repoName = $repos[$i] $pct = [math]::Floor(($i / $repoTotal) * 100) Write-Progress -Activity "Scanning $Owner" ` -Status "Repo $($i + 1) of $repoTotal : $repoName" ` -PercentComplete $pct ` -Id 1 $repoResults = Invoke-FylgyrScan -Owner $Owner -Repo $repoName -Token $Token foreach ($result in $repoResults) { $allResults.Add($result) } $scannedTargets.Add("$Owner/$repoName") } Write-Progress -Activity "Scanning $Owner" -Id 1 -Completed } else { $repoResults = Invoke-FylgyrScan -Owner $Owner -Repo $Repo -Token $Token foreach ($result in $repoResults) { $allResults.Add($result) } $scannedTargets.Add("$Owner/$Repo") } } end { if ($allResults.Count -eq 0) { return } $resultsArray = $allResults.ToArray() # Derive display target from scanned targets $displayTarget = if ($scannedTargets.Count -eq 1) { $scannedTargets[0] } elseif ($scannedTargets.Count -gt 1) { $owners = @($scannedTargets | ForEach-Object { ($_ -split '/')[0] } | Sort-Object -Unique) if ($owners.Count -eq 1) { $owners[0] } else { "$($scannedTargets.Count) repositories" } } else { 'unknown' } if ($OutputFormat -eq 'JSON') { ConvertTo-FylgyrJson -Results $resultsArray -Target $displayTarget } elseif ($OutputFormat -eq 'SARIF') { ConvertTo-FylgyrSarif -Results $resultsArray } elseif ($OutputFormat -eq 'Console') { Write-FylgyrConsole -Results $resultsArray -Target $displayTarget -ScannedRepoCount $scannedTargets.Count } else { $resultsArray } } } function Invoke-FylgyrScan { [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 ) $target = "$Owner/$Repo" $results = [System.Collections.Generic.List[PSCustomObject]]::new() Write-Progress -Activity $target -Status 'Fetching workflow files...' -Id 2 -ParentId 1 $workflowFiles = $null $fetchFailed = $false try { $workflowFiles = @(Get-WorkflowFile -Owner $Owner -Repo $Repo -Token $Token) } catch { $fetchFailed = $true $results.Add((Format-FylgyrResult ` -CheckName 'WorkflowFileFetch' ` -Status 'Error' ` -Severity 'Critical' ` -Resource $target ` -Detail "Failed to fetch workflow files: $($_.Exception.Message)" ` -Remediation 'Verify the repository exists and the token has contents:read access.' ` -Target $target)) } if ($fetchFailed) { # Error already recorded above } elseif ($workflowFiles.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'WorkflowFileFetch' ` -Status 'Warning' ` -Severity 'Info' ` -Resource $target ` -Detail 'No workflow files found in .github/workflows.' ` -Remediation 'No action needed if this repository does not use GitHub Actions.' ` -Target $target)) } else { $workflowChecks = @( @{ Name = 'Test-ActionPinning'; Params = @{ WorkflowFiles = $workflowFiles } } @{ Name = 'Test-DangerousTrigger'; Params = @{ WorkflowFiles = $workflowFiles; Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-WorkflowPermission'; Params = @{ WorkflowFiles = $workflowFiles } } @{ Name = 'Test-RunnerHygiene'; Params = @{ WorkflowFiles = $workflowFiles; Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-EgressControl'; Params = @{ WorkflowFiles = $workflowFiles } } @{ Name = 'Test-ForkPullPolicy'; Params = @{ WorkflowFiles = $workflowFiles } } ) for ($c = 0; $c -lt $workflowChecks.Count; $c++) { $check = $workflowChecks[$c] $checkPct = [math]::Floor(($c / $workflowChecks.Count) * 100) Write-Progress -Activity $target ` -Status "Running $($check.Name) ($($workflowFiles.Count) workflow files)" ` -PercentComplete $checkPct ` -Id 2 -ParentId 1 try { $checkParams = $check.Params $checkResults = & $check.Name @checkParams foreach ($r in $checkResults) { $r.Target = $target $results.Add($r) } } catch { $results.Add((Format-FylgyrResult ` -CheckName $check.Name ` -Status 'Error' ` -Severity 'Critical' ` -Resource $target ` -Detail "Check failed with error: $($_.Exception.Message)" ` -Remediation 'Review the error and re-run.' ` -Target $target)) } } } # Fork secret exposure check (needs workflow files + API params) if (-not $fetchFailed -and $workflowFiles -and $workflowFiles.Count -gt 0) { Write-Progress -Activity $target -Status 'Running Test-ForkSecretExposure' -Id 2 -ParentId 1 try { $checkResults = Test-ForkSecretExposure -WorkflowFiles $workflowFiles -Owner $Owner -Repo $Repo -Token $Token foreach ($r in $checkResults) { $r.Target = $target $results.Add($r) } } catch { $results.Add((Format-FylgyrResult ` -CheckName 'Test-ForkSecretExposure' ` -Status 'Error' ` -Severity 'Critical' ` -Resource $target ` -Detail "Check failed with error: $($_.Exception.Message)" ` -Remediation 'Review the error and re-run.' ` -Target $target)) } } # Repo-level checks (always run, regardless of workflow files) $repoChecks = @( @{ Name = 'Test-BranchProtection'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-SecretScanning'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-DependabotAlert'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-CodeScanning'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-CodeOwner'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-SignedCommit'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-EnvironmentProtection'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } @{ Name = 'Test-RepoVisibility'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } } ) foreach ($entry in $repoChecks) { Write-Progress -Activity $target -Status "Running $($entry.Name)" -Id 2 -ParentId 1 try { $checkParams = $entry.Params $checkResults = & $entry.Name @checkParams foreach ($r in $checkResults) { $r.Target = $target $results.Add($r) } } catch { $results.Add((Format-FylgyrResult ` -CheckName $entry.Name ` -Status 'Error' ` -Severity 'Critical' ` -Resource $target ` -Detail "Check failed with error: $($_.Exception.Message)" ` -Remediation 'Review the error and re-run.' ` -Target $target)) } } # Owner-level check: GitHub App Security. # Owner-level API - emit exactly once per Owner across an org-wide scan so we do # not duplicate findings for every repository under the same owner. $cacheReady = $script:FylgyrOwnerAppSecurityResults -is [hashtable] -and $script:FylgyrOwnerAppSecurityEmitted -is [hashtable] if (-not $cacheReady -or -not $script:FylgyrOwnerAppSecurityResults.ContainsKey($Owner)) { Write-Progress -Activity $target -Status 'Running Test-GitHubAppSecurity' -Id 2 -ParentId 1 try { $appSecResults = @(Test-GitHubAppSecurity -Owner $Owner -Token $Token) if ($cacheReady) { $script:FylgyrOwnerAppSecurityResults[$Owner] = $appSecResults } foreach ($r in $appSecResults) { $r.Target = $target $results.Add($r) } if ($cacheReady) { $script:FylgyrOwnerAppSecurityEmitted[$Owner] = $true } } catch { $results.Add((Format-FylgyrResult ` -CheckName 'GitHubAppSecurity' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $target ` -Detail "Check failed with error: $($_.Exception.Message)" ` -Remediation 'Review the error and re-run.' ` -Target $target)) } } Write-Progress -Activity $target -Id 2 -Completed $results.ToArray() } |