Skip to content

Commit f5a67fd

Browse files
Merge pull request #47 from devopsabcs-engineering/feature/46-ghas-policy-enforcement
feat(scripts,workflows): add GHAS policy enforcement for public repos
2 parents 9bce2ae + 379490d commit f5a67fd

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# ---------------------------------------------------------
2+
# Enforce GHAS Policy
3+
# Runs on a schedule (daily) and on manual dispatch to
4+
# ensure all public repos in the org have GitHub Advanced
5+
# Security features enabled.
6+
#
7+
# Required secret:
8+
# ORG_SECURITY_TOKEN — a PAT (classic) or fine-grained PAT
9+
# with the following scopes:
10+
# Classic PAT: repo, admin:org, security_events
11+
# Fine-grained: Organization permissions → Administration (write)
12+
# Repository permissions → Administration (write),
13+
# Secret scanning alerts (write),
14+
# Code scanning alerts (write),
15+
# Vulnerability alerts (read/write),
16+
# Dependabot secrets (read/write)
17+
# ---------------------------------------------------------
18+
name: Enforce GHAS Policy
19+
20+
on:
21+
schedule:
22+
# Run daily at 06:00 UTC
23+
- cron: '0 6 * * *'
24+
workflow_dispatch:
25+
inputs:
26+
dry_run:
27+
description: 'Dry run (no changes)'
28+
required: false
29+
default: 'false'
30+
type: choice
31+
options:
32+
- 'false'
33+
- 'true'
34+
35+
permissions:
36+
contents: read
37+
38+
env:
39+
ORG_NAME: devopsabcs-engineering
40+
41+
jobs:
42+
enforce:
43+
name: Enforce GHAS on public repos
44+
runs-on: ubuntu-latest
45+
46+
steps:
47+
- name: Checkout repository
48+
uses: actions/checkout@v4
49+
50+
- name: Authenticate GitHub CLI
51+
env:
52+
GH_TOKEN: ${{ secrets.ORG_SECURITY_TOKEN }}
53+
run: gh auth status
54+
55+
- name: Run enforcement script
56+
env:
57+
GH_TOKEN: ${{ secrets.ORG_SECURITY_TOKEN }}
58+
shell: pwsh
59+
run: |
60+
$dryRun = '${{ github.event.inputs.dry_run }}' -eq 'true'
61+
$params = @{ Org = '${{ env.ORG_NAME }}' }
62+
if ($dryRun) { $params['DryRun'] = $true }
63+
& ./scripts/enforce-ghas-policy.ps1 @params
64+
65+
- name: Summary
66+
if: always()
67+
run: |
68+
echo "### GHAS Policy Enforcement" >> $GITHUB_STEP_SUMMARY
69+
echo "" >> $GITHUB_STEP_SUMMARY
70+
echo "- **Organization:** ${{ env.ORG_NAME }}" >> $GITHUB_STEP_SUMMARY
71+
echo "- **Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
72+
echo "- **Dry run:** ${{ github.event.inputs.dry_run || 'false' }}" >> $GITHUB_STEP_SUMMARY
73+
echo "- **Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY

scripts/enforce-ghas-policy.ps1

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<#
2+
.SYNOPSIS
3+
Enforces GitHub Advanced Security features on all public repositories
4+
in an organization. Free for public repos.
5+
6+
.DESCRIPTION
7+
Enables the following GHAS features on every public repo in the org:
8+
- Dependency graph (on by default for public repos)
9+
- Dependabot alerts
10+
- Dependabot security updates
11+
- Secret scanning
12+
- Secret scanning push protection
13+
- Code scanning default setup (CodeQL)
14+
- Private vulnerability reporting
15+
16+
Also configures org-level settings so new repos inherit these defaults.
17+
18+
.PARAMETER Org
19+
The GitHub organization name (e.g. devopsabcs-engineering).
20+
21+
.PARAMETER DryRun
22+
If set, prints what would be changed without making API calls.
23+
24+
.PARAMETER SkipOrgDefaults
25+
If set, skips updating organization-level default settings.
26+
27+
.EXAMPLE
28+
.\enforce-ghas-policy.ps1 -Org devopsabcs-engineering
29+
.\enforce-ghas-policy.ps1 -Org devopsabcs-engineering -DryRun
30+
#>
31+
[CmdletBinding()]
32+
param(
33+
[Parameter(Mandatory = $true)]
34+
[string]$Org,
35+
36+
[switch]$DryRun,
37+
38+
[switch]$SkipOrgDefaults
39+
)
40+
41+
Set-StrictMode -Version Latest
42+
$ErrorActionPreference = 'Stop'
43+
44+
# ---------- helpers ----------
45+
function Write-Status {
46+
param([string]$Repo, [string]$Feature, [string]$Result)
47+
$icon = switch ($Result) {
48+
'enabled' { '[+]' }
49+
'skipped' { '[~]' }
50+
'failed' { '[!]' }
51+
'dry-run' { '[?]' }
52+
default { '[ ]' }
53+
}
54+
Write-Host " $icon $Feature : $Result" -ForegroundColor $(
55+
switch ($Result) { 'enabled' { 'Green' } 'failed' { 'Red' } 'skipped' { 'Yellow' } default { 'Cyan' } }
56+
)
57+
}
58+
59+
function Invoke-GhApi {
60+
param(
61+
[string]$Method,
62+
[string]$Endpoint,
63+
[string]$Body
64+
)
65+
$apiArgs = @('api', '-X', $Method, $Endpoint, '--silent')
66+
if ($Body) {
67+
$apiArgs += @('--input', '-')
68+
$result = $Body | & gh @apiArgs 2>&1
69+
}
70+
else {
71+
$result = & gh @apiArgs 2>&1
72+
}
73+
74+
$exitCode = if (Test-Path variable:global:LASTEXITCODE) { $global:LASTEXITCODE } else { 0 }
75+
if ($exitCode -ne 0) {
76+
return @{ success = $false; output = ($result -join "`n") }
77+
}
78+
return @{ success = $true; output = ($result -join "`n") }
79+
}
80+
81+
# ---------- pre-flight ----------
82+
try {
83+
$ghVersionOutput = & gh --version 2>&1
84+
$ghVersion = ($ghVersionOutput | Select-Object -First 1)
85+
}
86+
catch {
87+
Write-Error 'GitHub CLI (gh) is not installed or not on PATH.'
88+
}
89+
if (-not $ghVersion) {
90+
Write-Error 'GitHub CLI (gh) is not installed or not on PATH.'
91+
}
92+
Write-Host "Using $ghVersion"
93+
Write-Host "Organization: $Org"
94+
if ($DryRun) { Write-Host '*** DRY RUN — no changes will be made ***' -ForegroundColor Cyan }
95+
Write-Host ''
96+
97+
# ---------- 1. Org-level defaults for new repos ----------
98+
if (-not $SkipOrgDefaults) {
99+
Write-Host '=== Configuring organization-level defaults ===' -ForegroundColor White
100+
$orgBody = @{
101+
dependabot_alerts_enabled_for_new_repositories = $true
102+
dependabot_security_updates_enabled_for_new_repositories = $true
103+
dependency_graph_enabled_for_new_repositories = $true
104+
secret_scanning_enabled_for_new_repositories = $true
105+
secret_scanning_push_protection_enabled_for_new_repositories = $true
106+
} | ConvertTo-Json -Compress
107+
108+
if ($DryRun) {
109+
Write-Host ' [?] Would set org defaults: dependabot alerts, dependabot security updates, dependency graph, secret scanning, push protection' -ForegroundColor Cyan
110+
}
111+
else {
112+
$r = Invoke-GhApi -Method 'PATCH' -Endpoint "/orgs/$Org" -Body $orgBody
113+
if ($r.success) {
114+
Write-Host ' [+] Org defaults configured for new repositories.' -ForegroundColor Green
115+
}
116+
else {
117+
Write-Host " [!] Failed to set org defaults: $($r.output)" -ForegroundColor Red
118+
}
119+
}
120+
Write-Host ''
121+
}
122+
123+
# ---------- 2. Discover public repos ----------
124+
Write-Host '=== Discovering public repositories ===' -ForegroundColor White
125+
$reposJson = & gh api "/orgs/$Org/repos" --paginate -q '[ .[] | select(.visibility == "public") | { name: .name, full_name: .full_name, archived: .archived, fork: .fork } ]' 2>&1
126+
$exitCode = if (Test-Path variable:global:LASTEXITCODE) { $global:LASTEXITCODE } else { 1 }
127+
if ($exitCode -ne 0) {
128+
Write-Error "Failed to list repos: $reposJson"
129+
}
130+
$repos = $reposJson | ConvertFrom-Json
131+
Write-Host " Found $($repos.Count) public repo(s).`n"
132+
133+
# ---------- 3. Per-repo enforcement ----------
134+
$summary = @{ enabled = 0; skipped = 0; failed = 0 }
135+
136+
foreach ($repo in $repos) {
137+
$fullName = $repo.full_name
138+
$repoName = $repo.name
139+
140+
if ($repo.archived) {
141+
Write-Host "--- $fullName (ARCHIVED — skipping) ---" -ForegroundColor DarkGray
142+
$summary.skipped++
143+
continue
144+
}
145+
146+
Write-Host "--- $fullName ---" -ForegroundColor White
147+
148+
# 3a. Dependabot alerts
149+
if ($DryRun) {
150+
Write-Status $repoName 'Dependabot alerts' 'dry-run'
151+
}
152+
else {
153+
$r = Invoke-GhApi -Method 'PUT' -Endpoint "/repos/$fullName/vulnerability-alerts"
154+
Write-Status $repoName 'Dependabot alerts' $(if ($r.success) { 'enabled' } else { 'failed' })
155+
if (-not $r.success) { $summary.failed++ }
156+
}
157+
158+
# 3b. Dependabot security updates
159+
if ($DryRun) {
160+
Write-Status $repoName 'Dependabot security updates' 'dry-run'
161+
}
162+
else {
163+
$r = Invoke-GhApi -Method 'PUT' -Endpoint "/repos/$fullName/automated-security-fixes"
164+
Write-Status $repoName 'Dependabot security updates' $(if ($r.success) { 'enabled' } else { 'failed' })
165+
if (-not $r.success) { $summary.failed++ }
166+
}
167+
168+
# 3c. Secret scanning + push protection
169+
$secBody = @{
170+
security_and_analysis = @{
171+
secret_scanning = @{ status = 'enabled' }
172+
secret_scanning_push_protection = @{ status = 'enabled' }
173+
}
174+
} | ConvertTo-Json -Depth 4 -Compress
175+
176+
if ($DryRun) {
177+
Write-Status $repoName 'Secret scanning + push protection' 'dry-run'
178+
}
179+
else {
180+
$r = Invoke-GhApi -Method 'PATCH' -Endpoint "/repos/$fullName" -Body $secBody
181+
Write-Status $repoName 'Secret scanning + push protection' $(if ($r.success) { 'enabled' } else { 'failed' })
182+
if (-not $r.success) { $summary.failed++ }
183+
}
184+
185+
# 3d. Code scanning default setup (CodeQL)
186+
$csBody = @{
187+
state = 'configured'
188+
} | ConvertTo-Json -Compress
189+
190+
if ($DryRun) {
191+
Write-Status $repoName 'Code scanning default setup (CodeQL)' 'dry-run'
192+
}
193+
else {
194+
$r = Invoke-GhApi -Method 'PATCH' -Endpoint "/repos/$fullName/code-scanning/default-setup" -Body $csBody
195+
if ($r.success) {
196+
Write-Status $repoName 'Code scanning default setup (CodeQL)' 'enabled'
197+
}
198+
else {
199+
# Code scanning may fail for repos with no supported languages
200+
Write-Status $repoName 'Code scanning default setup (CodeQL)' 'skipped'
201+
Write-Host " Reason: $($r.output)" -ForegroundColor DarkGray
202+
}
203+
}
204+
205+
# 3e. Private vulnerability reporting
206+
if ($DryRun) {
207+
Write-Status $repoName 'Private vulnerability reporting' 'dry-run'
208+
}
209+
else {
210+
$r = Invoke-GhApi -Method 'PUT' -Endpoint "/repos/$fullName/private-vulnerability-reporting"
211+
Write-Status $repoName 'Private vulnerability reporting' $(if ($r.success) { 'enabled' } else { 'failed' })
212+
if (-not $r.success) { $summary.failed++ }
213+
}
214+
215+
if (-not $DryRun) { $summary.enabled++ }
216+
Write-Host ''
217+
}
218+
219+
# ---------- 4. Summary ----------
220+
Write-Host '=== Summary ===' -ForegroundColor White
221+
Write-Host " Repos processed : $($summary.enabled)"
222+
Write-Host " Repos skipped : $($summary.skipped)"
223+
Write-Host " Feature failures: $($summary.failed)"
224+
if ($summary.failed -gt 0) {
225+
Write-Host ' Review failures above. Common causes: insufficient permissions, repo has no supported languages for CodeQL.' -ForegroundColor Yellow
226+
}
227+
Write-Host 'Done.' -ForegroundColor Green

0 commit comments

Comments
 (0)