Public/Test-WebhookSecurity.ps1
|
function Test-WebhookSecurity { [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() $resource = $target try { $hooks = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/hooks?per_page=100" -Token $Token -AllPages } catch { $msg = $_.Exception.Message if ($msg -match '404') { $results.Add((Format-FylgyrResult ` -CheckName 'WebhookSecurity' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail 'No webhooks configured on this repository.' ` -Remediation 'No action needed.' ` -Target $target)) return $results.ToArray() } if ($msg -match '403') { $results.Add((Format-FylgyrResult ` -CheckName 'WebhookSecurity' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail 'Insufficient permissions to read webhook configuration. Requires admin:repo_hook scope.' ` -Remediation 'Use a fine-grained token with Webhooks:read permission, or a classic token with admin:repo_hook scope, to audit webhook secrets.' ` -Target $target)) return $results.ToArray() } $results.Add((Format-FylgyrResult ` -CheckName 'WebhookSecurity' ` -Status 'Error' ` -Severity 'Low' ` -Resource $resource ` -Detail "Unexpected error reading webhook configuration: $($_.Exception.Message)" ` -Remediation 'Re-run with a valid token and verify network access to api.github.com.' ` -Target $target)) return $results.ToArray() } if (-not $hooks -or $hooks.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'WebhookSecurity' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail 'No webhooks configured on this repository.' ` -Remediation 'No action needed.' ` -Target $target)) return $results.ToArray() } $unsecured = [System.Collections.Generic.List[string]]::new() foreach ($hook in $hooks) { $hasSecret = $hook.config.PSObject.Properties['secret'] -and $null -ne $hook.config.secret -and $hook.config.secret -ne '' if (-not $hasSecret) { # Report only the hostname to avoid leaking credentials or tokens # that may be embedded in webhook URLs as query parameters $hostOnly = try { ([System.Uri]$hook.config.url).Host } catch { '(unknown host)' } $unsecured.Add($hostOnly) } } if ($unsecured.Count -gt 0) { $results.Add((Format-FylgyrResult ` -CheckName 'WebhookSecurity' ` -Status 'Fail' ` -Severity 'Low' ` -Resource $resource ` -Detail "$($unsecured.Count) webhook(s) have no secret token configured. Without a shared secret, receivers cannot verify that payloads originate from GitHub — an attacker who discovers the webhook URL can forge or replay events to trigger downstream CI, deploy, or chat automation, as in the Codecov bash uploader integrity gap. Unsecured receiver hosts: $($unsecured -join ', ')." ` -Remediation 'Set a webhook secret in Settings → Webhooks → Edit, then validate the X-Hub-Signature-256 header in the receiving service. See https://docs.github.com/webhooks/using-webhooks/validating-webhook-deliveries.' ` -AttackMapping @('codecov-bash-uploader') ` -Target $target)) } else { $results.Add((Format-FylgyrResult ` -CheckName 'WebhookSecurity' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail "All $($hooks.Count) webhook(s) have a secret token configured." ` -Remediation 'No action needed.' ` -Target $target)) } $results.ToArray() } |