Public/Test-CodeOwner.ps1
|
function Test-CodeOwner { [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() # Personal GitHub accounts cannot create teams, so CODEOWNERS gaps can't be # fully closed - downgrade findings to Warning to keep the signal without # failing dogfood on a structural limitation. $ownerType = 'Organization' try { $ownerInfo = Invoke-GitHubApi -Endpoint "users/$Owner" -Token $Token if ($ownerInfo -and $ownerInfo.PSObject.Properties['type']) { $ownerType = $ownerInfo.type } } catch { Write-Debug "Could not determine owner type for '$Owner': $($_.Exception.Message)" } $gapStatus = if ($ownerType -eq 'User') { 'Warning' } else { 'Fail' } $personalNote = if ($ownerType -eq 'User') { ' Note: this is a personal GitHub account - teams are unavailable, so full remediation requires adding a collaborator co-owner or migrating the repo to an organization.' } else { '' } $candidatePaths = @('CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS') $codeownersContent = $null $foundPath = $null foreach ($path in $candidatePaths) { try { $file = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/contents/$path" -Token $Token if ($file -and $file.content) { try { $codeownersContent = [System.Text.Encoding]::UTF8.GetString( [System.Convert]::FromBase64String(($file.content -replace '\s', '')) ) $foundPath = $path break } catch { Write-Debug "Failed to decode CODEOWNERS at ${path}: $($_.Exception.Message)" } } } catch { $msg = $_.Exception.Message if ($msg -match '404') { continue } if ($msg -match '403') { $results.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $target ` -Detail 'Insufficient permissions to read repository contents for CODEOWNERS.' ` -Remediation 'Use a fine-grained token with contents:read permission, or a classic token with repo scope.' ` -Target $target)) return $results.ToArray() } $results.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $target ` -Detail "Unexpected error reading CODEOWNERS candidate '${path}': $($_.Exception.Message)" ` -Remediation 'Re-run with a valid token and verify network access to api.github.com.' ` -Target $target)) return $results.ToArray() } } if (-not $codeownersContent) { $results.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status $gapStatus ` -Severity 'Medium' ` -Resource $target ` -Detail ("No CODEOWNERS file found at CODEOWNERS, .github/CODEOWNERS, or docs/CODEOWNERS. Without code owners, a single compromised maintainer can merge unreviewed changes - the exact pattern exploited in the xz-utils backdoor." + $personalNote) ` -Remediation 'Create a CODEOWNERS file under .github/CODEOWNERS that assigns at least two distinct owners to security-sensitive paths. See: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners' ` -AttackMapping @('xz-utils-backdoor') ` -Target $target)) return $results.ToArray() } # Parse rules: skip comments and blank lines. Rule = pattern + one-or-more owners. $rules = foreach ($line in ($codeownersContent -split "`n")) { $trimmed = $line.Trim() if (-not $trimmed -or $trimmed.StartsWith('#')) { continue } $tokens = -split $trimmed if ($tokens.Count -lt 2) { continue } $owners = @($tokens | Select-Object -Skip 1 | Where-Object { $_ -match '^@' -or $_ -match '@' }) [PSCustomObject]@{ Pattern = $tokens[0] Owners = $owners } } $distinctOwners = @($rules.Owners | Sort-Object -Unique) $catchAllRules = @($rules | Where-Object { $_.Pattern -eq '*' }) $findings = [System.Collections.Generic.List[PSCustomObject]]::new() if ($rules.Count -eq 0) { $findings.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status $gapStatus ` -Severity 'Medium' ` -Resource "$target ($foundPath)" ` -Detail ("CODEOWNERS file exists at '$foundPath' but contains no rules." + $personalNote) ` -Remediation 'Add at least one pattern with two or more distinct owners.' ` -AttackMapping @('xz-utils-backdoor') ` -Target $target)) } else { if ($distinctOwners.Count -lt 2) { $onlyOwner = if ($distinctOwners.Count -eq 1) { $distinctOwners[0] } else { '(none)' } $findings.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status $gapStatus ` -Severity 'Medium' ` -Resource "$target ($foundPath)" ` -Detail ("CODEOWNERS assigns ownership to only $($distinctOwners.Count) distinct owner ($onlyOwner). A single compromised or socially-engineered maintainer can merge malicious code, as in the xz-utils backdoor." + $personalNote) ` -Remediation 'Assign at least two distinct owners (users or teams) in CODEOWNERS so every change requires review by someone other than the author.' ` -AttackMapping @('xz-utils-backdoor') ` -Target $target)) } foreach ($rule in $catchAllRules) { if ($rule.Owners.Count -le 1) { $soleOwner = if ($rule.Owners.Count -eq 1) { $rule.Owners[0] } else { '(none)' } $findings.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status $gapStatus ` -Severity 'Medium' ` -Resource "$target ($foundPath)" ` -Detail ("Catch-all pattern '*' is assigned to a single owner ($soleOwner). Any change in the repository can be approved by that one account." + $personalNote) ` -Remediation "Replace the catch-all rule with multiple reviewers (e.g. '* @org/security @org/maintainers') or scope ownership by directory." ` -AttackMapping @('xz-utils-backdoor') ` -Target $target)) } } } if ($findings.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'CodeOwner' ` -Status 'Pass' ` -Severity 'Info' ` -Resource "$target ($foundPath)" ` -Detail "CODEOWNERS found with $($rules.Count) rule(s) and $($distinctOwners.Count) distinct owner(s). No single-owner catch-all detected." ` -Remediation 'No action needed.' ` -Target $target)) } else { foreach ($f in $findings) { $results.Add($f) } } $results.ToArray() } |