GitHub Hardening Guide
Comprehensive source control and CI/CD security hardening for GitHub organizations, Actions, supply chain protection, and Enterprise Cloud/Server
GitHub Editions Covered: GitHub.com (Free, Team, Enterprise Cloud), GitHub Enterprise Server
Overview
This guide provides comprehensive security hardening recommendations for GitHub, organized by control category. GitHub is a critical part of the software supply chain – compromises can lead to malicious code injection, secret theft, and downstream customer breaches. GitHub Enterprise powers software development for over 100 million developers worldwide, with Enterprise deployments managing critical source code, CI/CD pipelines, and secrets for Fortune 500 companies.
Intended Audience
- Security engineers managing GitHub organizations
- DevOps/Platform engineers configuring CI/CD pipelines
- Application security teams governing third-party Actions
- GRC professionals assessing code repository security
- Platform engineers implementing secure SDLC
- Open source maintainers protecting projects
How to Use This Guide
- L1 (Baseline): Essential controls for all organizations
- L2 (Hardened): Enhanced controls for organizations building customer-facing software or security-sensitive environments
- L3 (Maximum Security): Strictest controls for highly regulated or high-risk targets
Scope
This guide covers GitHub.com and GitHub Enterprise Cloud/Server security configurations including organization settings, repository security, Actions hardening, GitHub Advanced Security (GHAS), and supply chain protection. For self-hosted runner infrastructure hardening (Kubernetes, VMs), refer to CIS Benchmarks for those platforms.
Why This Guide Exists
No CIS Benchmark or DISA STIG currently exists for GitHub. This guide fills that gap using:
- GitHub’s official security hardening documentation
- SLSA (Supply-chain Levels for Software Artifacts) framework
- OpenSSF (Open Source Security Foundation) best practices
- Lessons from real-world supply chain attacks
Table of Contents
- Authentication & Access Controls
- Repository Security
- GitHub Actions & CI/CD Security
- OAuth & Third-Party App Security
- Secret Management
- Dependency & Supply Chain Security
- Modern Platform Features
- Monitoring & Audit Logging
- Third-Party Integration Security
1. Authentication & Access Controls
1.1 Enforce Multi-Factor Authentication (MFA) for All Organization Members
Profile Level: L1 (Baseline) NIST 800-53: IA-2(1), IA-2(2) CIS Controls: 6.3, 6.5
Description
Require all organization members to enable MFA on their GitHub accounts. This prevents account takeover via password compromise.
Rationale
Attack Prevented: Credential stuffing, password spray, phished credentials
Real-World Incidents:
- CircleCI Breach (January 2023): Attackers compromised employee laptop, pivoted to GitHub, stole OAuth tokens from thousands of customers. MFA on GitHub would have limited lateral movement.
- Heroku/Travis CI (April 2022): GitHub OAuth tokens stolen, used to access customer repositories. MFA enforcement would have required additional authentication.
Why This Matters: GitHub accounts with write access can inject malicious code into your supply chain affecting all downstream users.
Prerequisites
- GitHub organization owner/admin access
- Member communication plan (give 30-day notice before enforcement)
ClickOps Implementation
Step 1: Enable MFA Requirement
- Navigate to: Organization Settings -> Authentication security
- Under “Two-factor authentication”:
- Select “Require two-factor authentication for everyone in the [org-name] organization”
- Set grace period (recommended: 30 days)
- Click “Save”
Step 2: Monitor Compliance
- Go to: Organization Settings -> People
- Filter by “2FA” status to see non-compliant members
- Members without 2FA will be removed from org after grace period
Time to Complete: ~5 minutes + 30-day rollout
Code Implementation
Code Pack: Terraform
resource "github_organization_settings" "security" {
billing_email = var.github_organization
two_factor_requirement = true
}
Code Pack: API Script
# Enable two-factor authentication requirement for the organization
info "1.01 Enabling 2FA requirement for org ${GITHUB_ORG}..."
RESPONSE=$(gh_patch "/orgs/${GITHUB_ORG}" '{
"two_factor_requirement_enabled": true
}') || {
fail "1.01 Failed to enable 2FA requirement -- may require owner permissions"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'org.disable_two_factor_requirement'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Validation & Testing
- Create test user account, add to organization
- Verify test user is prompted to enable 2FA
- Confirm user cannot access org resources without 2FA setup
- After grace period, verify non-compliant users are removed
Expected result: All org members have 2FA enabled or are automatically removed.
Monitoring & Maintenance
Alert Configuration:
Code Pack: API Script
# Daily check for non-compliant members
gh api /orgs/{org}/members?filter=2fa_disabled --jq 'length'
# Expected: 0
# If > 0, alert security team
Maintenance schedule:
- Weekly: Review new member 2FA status
- Monthly: Audit removed users (were they removed due to 2FA non-compliance?)
Operational Impact
| Aspect | Impact Level | Details |
|---|---|---|
| User Experience | Medium | Users must set up 2FA app/hardware key |
| Onboarding | Low | New members guided through 2FA setup |
| Maintenance | Low | Automated enforcement, no ongoing admin work |
| Rollback | Easy | Disable requirement in org settings |
Potential Issues:
- Issue 1: Users locked out if they lose 2FA device
- Mitigation: Provide recovery code guidance, admin can reset 2FA
- Documentation: https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/recovering-your-account-if-you-lose-your-2fa-credentials
Rollback Procedure:
- Organization Settings -> Authentication security
- Uncheck “Require two-factor authentication”
- Save (not recommended - only for emergency access recovery)
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access security - MFA |
| NIST 800-53 | IA-2(1), IA-2(2) | Multi-factor authentication |
| ISO 27001 | A.9.4.2 | Secure log-on procedures |
| PCI DSS | 8.3 | Multi-factor authentication for all access |
1.2 Restrict Base Permissions for Organization Members
Profile Level: L1 (Baseline) NIST 800-53: AC-6 (Least Privilege)
Description
Set default organization member permissions to minimal access. Members should only have write access to repositories they actively work on.
Rationale
Attack Impact: When CircleCI was breached, attackers gained access to GitHub OAuth tokens. If those tokens had overly broad permissions, attackers could modify any repository in affected organizations.
Least Privilege Principle: Default to no repository access; grant write access only as needed.
ClickOps Implementation
Step 1: Set Base Permissions
- Organization Settings -> Member privileges
- Under “Base permissions”:
- Set to “No permission” (recommended) or “Read”
- NOT “Write” or “Admin”
- Click “Save”
Step 2: Use Teams for Access
- Create teams for projects/repos
- Grant teams specific repository access
- Add members to relevant teams only
Step 3: Configure Additional Member Privileges
- Navigate to: Organization Settings -> Member privileges
- Configure:
- Repository creation: Restrict to specific roles
- Repository forking: Disable for private repos
- Pages creation: Restrict as needed
Code Implementation
Code Pack: Terraform
resource "github_organization_settings" "permissions" {
billing_email = var.github_organization
default_repository_permission = "read"
}
Code Pack: API Script
# Restrict default repository permission to read-only
info "1.02 Setting default repository permission to 'read'..."
RESPONSE=$(gh_patch "/orgs/${GITHUB_ORG}" '{
"default_repository_permission": "read"
}') || {
fail "1.02 Failed to set default repository permission"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'org.update_default_repository_permission'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Compliance Mappings
- SOC 2: CC6.2 (Least privilege)
- NIST 800-53: AC-6
1.3 Enable SAML Single Sign-On (SSO) and SCIM Provisioning
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud NIST 800-53: IA-2, IA-4, IA-8 CIS Controls: 6.3, 12.5
Description
Integrate GitHub with your corporate identity provider (Okta, Azure AD, Google Workspace) via SAML SSO and configure SCIM provisioning for automated user lifecycle management. This centralizes authentication and enables conditional access policies.
Rationale
Centralized Control: If employee leaves company, disable their IdP account and they immediately lose GitHub access. SCIM enables automatic deprovisioning when employees leave, closing the gap between termination and access revocation.
Conditional Access: Enforce device compliance, location-based access, session timeouts via IdP.
SSO enables enforcement of corporate MFA policies – rather than relying on individual users to configure GitHub MFA, the organization’s IdP enforces consistent authentication requirements.
ClickOps Implementation
Step 1: Configure SAML SSO
- Navigate to: Enterprise Settings -> Authentication security
- Click Enable SAML authentication
- Configure SAML settings:
- Sign on URL
- Issuer
- Public certificate
- Configure attribute mappings
- Test authentication with pilot users before requiring
Step 2: Require SAML SSO
- After testing, select Require SAML authentication
- Configure recovery codes for break-glass access
- Document emergency access procedures
- Enable “Require SAML SSO authentication for all members”
Step 3: Configure SCIM Provisioning
- Navigate to: Enterprise Settings -> Authentication security -> SCIM configuration
- Generate SCIM token
- Configure IdP SCIM provisioning:
- User provisioning
- Group synchronization
- Deprovisioning
- Test user lifecycle (create, update, deactivate)
Time to Complete: ~1 hour
Code Implementation
Code Pack: API Script
# Enable SAML SSO (requires Enterprise Cloud)
# Configuration done via GitHub web UI + IdP
# API can verify status:
gh api /orgs/{org} --jq '.saml_identity_provider'
Code Pack: Terraform
resource "github_organization_settings" "repo_creation" {
billing_email = var.github_organization
members_can_create_public_repositories = false
}
Code Pack: API Script
# Disable member ability to create public repositories
info "1.03 Disabling public repository creation..."
RESPONSE=$(gh_patch "/orgs/${GITHUB_ORG}" '{
"members_can_create_public_repositories": false
}') || {
fail "1.03 Failed to disable public repository creation"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'org.update_member_repository_creation_permission'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Additional Hardening
After SAML SSO is enabled:
- Configure session timeout in IdP (recommend: 8 hours max)
- Enable device trust if IdP supports (require managed devices)
- Verify SCIM deprovisioning works by testing with a non-production user
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Identity and access management |
| NIST 800-53 | IA-2, IA-4, IA-8 | Identification and authentication |
| ISO 27001 | A.9.2.1 | User registration and de-registration |
| CIS Controls | 6.3, 12.5 | Centralized authentication |
1.4 Configure Admin Access Controls
Profile Level: L1 (Baseline) Requires: GitHub Enterprise Cloud/Server (for enterprise-level controls) NIST 800-53: AC-6(1) (Least Privilege - Authorize Access to Security Functions) CIS Controls: 5.4
Description
Implement least privilege for organization and enterprise administrators. Limit the number of enterprise owners, enforce MFA for all admins, and use separate admin accounts for privileged operations.
Rationale
Why This Matters:
- Site admins can promote themselves, create powerful tokens, and access any repository
- Each admin account represents a high-value target for attackers
- Limiting admin scope reduces blast radius of a compromised admin account
- Enterprise owners have unrestricted access to all organizations and settings
ClickOps Implementation
Step 1: Review Enterprise Owners
- Navigate to: Enterprise Settings -> People -> Enterprise owners
- Limit to 2-3 essential personnel
- Ensure each owner has MFA enabled
- Document owner responsibilities
Step 2: Configure Organization Owner Policies
- Review organization owners across all orgs
- Limit to essential personnel per org
- Create separate admin accounts for privileged operations
- Enforce MFA for all admin accounts
Step 3: Audit Admin Activity
- Regular review of admin audit logs
- Alert on privilege escalation events
- Document admin changes and justifications
For GitHub Enterprise Server:
- Management Console admins have shell access
- Use passphrase-protected SSH keys per admin
- Restrict Management Console to bastion host access
Code Implementation
Code Pack: API Script
# List organization admins
info "1.08 Listing organization admins for ${GITHUB_ORG}..."
ADMINS=$(gh_get "/orgs/${GITHUB_ORG}/members?role=admin") || {
fail "1.08 Unable to retrieve admin list"
increment_failed
summary
exit 0
}
ADMIN_COUNT=$(echo "${ADMINS}" | jq '. | length')
echo "${ADMINS}" | jq -r '.[].login'
info "1.08 Found ${ADMIN_COUNT} admin(s)"
# Audit admin actions in audit log
info "1.08 Checking recent admin role changes..."
gh_get "/orgs/${GITHUB_ORG}/audit-log?phrase=action:org.update_member" \
| jq '.[] | {actor: .actor, action: .action, created_at: .created_at}'
Code Pack: Terraform
resource "github_organization_settings" "forking" {
billing_email = var.github_organization
members_can_fork_private_repositories = false
}
Code Pack: API Script
# Disable member ability to fork private repositories
info "1.04 Disabling private repository forking..."
RESPONSE=$(gh_patch "/orgs/${GITHUB_ORG}" '{
"members_can_fork_private_repositories": false
}') || {
fail "1.04 Failed to disable private repository forking"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'org.update_member_repository_forking_permission'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_organization_settings" "commit_signoff" {
billing_email = var.github_organization
web_commit_signoff_required = true
}
Code Pack: API Script
# Enable web commit sign-off requirement at the organization level
info "1.05 Enabling web commit sign-off requirement..."
RESPONSE=$(gh_patch "/orgs/${GITHUB_ORG}" '{
"web_commit_signoff_required": true
}') || {
fail "1.05 Failed to enable web commit sign-off requirement"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'org.update_default_repository_permission'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_organization_settings" "repo_deletion" {
billing_email = var.github_organization
default_repository_permission = "read"
members_can_create_repositories = false
}
Code Pack: API Script
# Restrict repository creation and set default permission to read-only
info "1.06 Restricting repository creation and default permissions..."
RESPONSE=$(gh_patch "/orgs/${GITHUB_ORG}" '{
"default_repository_permission": "read",
"members_can_create_repositories": false
}') || {
fail "1.06 Failed to restrict repository creation and permissions"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repo.destroy'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Validation & Testing
- Verify enterprise owner count is 2-3 maximum
- Confirm all admin accounts have MFA enabled
- Test that non-admin members cannot access admin settings
- Verify audit logging captures admin actions
Monitoring & Maintenance
Alert Configuration:
Code Pack: API Script
# Monitor for privilege escalation
gh api /orgs/{org}/audit-log?phrase=action:org.update_member_role \
--jq '.[] | select(.role == "admin") | {actor: .actor, user: .user, created_at: .created_at}'
Maintenance schedule:
- Monthly: Review admin roster and remove unnecessary privileges
- Quarterly: Full admin access audit with documented justification
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.2 | Least privilege access controls |
| NIST 800-53 | AC-6(1) | Authorize access to security functions |
| ISO 27001 | A.9.2.3 | Management of privileged access rights |
| CIS Controls | 5.4 | Restrict administrator privileges |
1.5 Configure Enterprise IP Allow List
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud NIST 800-53: AC-17, SC-7 CIS Controls: 13.5
Description
Restrict enterprise access to approved IP addresses using IP allow lists. This limits the network locations from which users and services can access your GitHub Enterprise instance.
Rationale
Attack Prevention: Even with stolen credentials or tokens, attackers outside your corporate network cannot access GitHub resources. This is a defense-in-depth measure that complements MFA and SSO.
ClickOps Implementation
Step 1: Enable IP Allow List
- Navigate to: Enterprise Settings -> Authentication security -> IP allow list
- Click Enable IP allow list
Step 2: Add Allowed IPs
- Click Add IP address or range
- Add corporate network IPs/CIDR ranges
- Add VPN egress IPs
- Add CI/CD runner IPs (if applicable)
- Enable for GitHub Apps: Optionally apply to installed GitHub Apps
Step 3: Validate Access
- Test access from allowed IPs
- Verify blocked access from other IPs
- Document emergency procedures if blocked
Code Implementation
Code Pack: API Script
# List current IP allow list entries
info "1.07 Listing IP allow list entries for ${GITHUB_ORG}..."
ENTRIES=$(gh_get "/orgs/${GITHUB_ORG}/ip-allow-list") || {
fail "1.07 Unable to retrieve IP allow list"
increment_failed
summary
exit 0
}
echo "${ENTRIES}" | jq '.[] | {name: .name, value: .value, is_active: .is_active}'
# Add IP range (idempotent — checks if already exists)
add_ip_entry() {
local name="$1" value="$2"
local existing
existing=$(echo "${ENTRIES}" | jq -r --arg v "${value}" '.[] | select(.value == $v) | .id')
if [ -n "${existing}" ]; then
pass "1.07 IP entry '${name}' (${value}) already exists"
return
fi
gh_post "/orgs/${GITHUB_ORG}/ip-allow-list" "{
\"name\": \"${name}\",
\"value\": \"${value}\",
\"is_active\": true
}" || {
fail "1.07 Failed to add IP entry '${name}' (${value})"
return
}
pass "1.07 Added IP entry '${name}' (${value})"
}
Validation & Testing
- Access GitHub from an allowed IP (should succeed)
- Access GitHub from a non-allowed IP (should fail)
- Verify CI/CD pipelines still function with runner IPs allowed
- Test emergency access procedures
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.6 | Network access restrictions |
| NIST 800-53 | AC-17, SC-7 | Remote access, boundary protection |
| ISO 27001 | A.13.1.1 | Network controls |
| CIS Controls | 13.5 | Manage access control to remote assets |
1.7 Enforce Fine-Grained Personal Access Token (PAT) Policies
Profile Level: L2 (Hardened) NIST 800-53: IA-5, AC-6 CIS Controls: 6.2
Description
Enforce fine-grained personal access token policies at the organization and enterprise level. Restrict or block classic PATs, require approval for fine-grained PATs, and set maximum token lifetimes. Fine-grained PATs (GA since March 2025) provide repository-level scoping and mandatory expiration, dramatically reducing blast radius compared to classic tokens.
Rationale
Attack Prevented: Overprivileged token theft, lateral movement via stolen credentials
Real-World Incident:
- Fake Dependabot Commits (July 2023): Attackers used stolen classic PATs to inject malicious commits disguised as Dependabot contributions. Fine-grained PATs with repository-level scoping and mandatory expiration would have limited the blast radius.
Why This Matters: Classic PATs grant broad access across all repositories a user can access. Fine-grained PATs enforce least privilege with repository-specific scoping, mandatory expiration, and admin approval workflows.
Prerequisites
- GitHub organization owner/admin access
- Enterprise Cloud for enterprise-level enforcement
ClickOps Implementation
Step 1: Restrict Classic PATs
- Navigate to: Organization Settings -> Personal access tokens -> Settings
- Select the Tokens (classic) tab
- Under “Restrict personal access tokens (classic) from accessing your organizations”:
- Select “Do not allow access via personal access tokens (classic)”
- Click Save
Step 2: Require Approval for Fine-Grained PATs
- Navigate to: Organization Settings -> Personal access tokens -> Settings
- Select the Fine-grained tokens tab
- Under “Require approval of fine-grained personal access tokens”:
- Select “Require administrator approval”
- Click Save
Step 3: Set Maximum Token Lifetime
- On the same settings page, under “Set maximum lifetimes for personal access tokens”:
- Set to 90 days (recommended) or per your organization’s policy
- Click Save
Step 4: Enterprise-Level Enforcement (Enterprise Cloud)
- Navigate to: Enterprise Settings -> Policies -> Personal access tokens
- Under Tokens (classic) tab:
- Select “Restrict access via personal access tokens (classic)”
- Under Fine-grained tokens tab:
- Select “Require approval”
- Set maximum lifetime policy
- Click Save
Time to Complete: ~10 minutes
Code Implementation
Code Pack: API Script
# List all fine-grained PATs with access to the organization
info "1.12 Listing fine-grained PATs with org access..."
PATS=$(gh_get "/orgs/${GITHUB_ORG}/personal-access-tokens?per_page=100") || {
fail "1.12 Unable to list fine-grained PATs (requires org admin permissions)"
increment_failed
summary
exit 0
}
PAT_COUNT=$(echo "${PATS}" | jq '. | length')
info "1.12 Found ${PAT_COUNT} fine-grained PAT(s) with org access"
# Check for PATs with excessive permissions
echo "${PATS}" | jq -r '.[] | "\(.owner.login) | \(.token_name) | expires: \(.token_expired_at // "never") | repos: \(.repository_selection)"'
# List pending PAT requests requiring approval
info "1.12 Listing pending fine-grained PAT requests..."
REQUESTS=$(gh_get "/orgs/${GITHUB_ORG}/personal-access-token-requests?per_page=100") || {
warn "1.12 Unable to list PAT requests"
}
if [ -n "${REQUESTS}" ]; then
REQ_COUNT=$(echo "${REQUESTS}" | jq '. | length')
if [ "${REQ_COUNT}" -gt "0" ]; then
warn "1.12 ${REQ_COUNT} pending PAT request(s) awaiting approval"
echo "${REQUESTS}" | jq -r '.[] | "\(.owner.login) | \(.token_name) | requested: \(.created_at)"'
else
pass "1.12 No pending PAT requests"
fi
fi
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'personal_access_token.create'
filter_fine_grained:
programmatic_access_grant_type: 'fine_grained'
condition: selection and not filter_fine_grained
fields:
- actor
- action
- org
- created_at
- programmatic_access_grant_type
Validation & Testing
- Attempt to create a classic PAT and access the organization (should fail if restricted)
- Create a fine-grained PAT and verify it requires admin approval
- Verify PAT expiration is enforced within the maximum lifetime
- Confirm enterprise policy overrides are applied to all organizations
Monitoring & Maintenance
Maintenance schedule:
- Weekly: Review pending fine-grained PAT approval requests
- Monthly: Audit active fine-grained PATs for excessive permissions
- Quarterly: Review and rotate long-lived PATs approaching expiration
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1, CC6.2 | Identity and access management, least privilege |
| NIST 800-53 | IA-5, AC-6 | Authenticator management, least privilege |
| ISO 27001 | A.9.4.3 | Password management system |
| CIS Controls | 6.2 | Establish an access revoking process |
1.8 Restrict Service Account Cross-Organization Access
Profile Level: L2 (Hardened) NIST 800-53: AC-6(3), AC-6(5) CIS Controls: 6.8
Description
Audit and restrict service accounts (bot accounts, machine users, GitHub App installations) to a single GitHub organization. Service accounts with membership or admin access across multiple organizations create a blast radius bridge — compromise of one organization grants the attacker access to all connected organizations.
Rationale
Attack Prevented: Cross-organization lateral movement via shared service accounts
Real-World Incident:
- TeamPCP / Aqua Security (March 2026): The
Argon-DevOps-Mgtservice account (GitHub ID 139343333) had write/admin access to both the publicaquasecurityorganization and the internalaquasec-comorganization. After compromising the public org via apull_request_targetexploit, attackers used this shared account to pivot to the internal org and deface all 44 internal repositories in a scripted 2-minute burst (20:31:07–20:32:26 UTC on March 22). Each repo was renamed with atpcp-docs-prefix, descriptions changed to “TeamPCP Owns Aqua Security,” and made public — exposing proprietary source code, CI/CD pipelines, Kubernetes operators, and team knowledge bases.
Why This Matters: A service account that bridges organizations turns a single-org compromise into a multi-org breach. The attacker doesn’t need to find a second vulnerability — the shared credential IS the vulnerability. This is especially dangerous when the shared account bridges public (open-source) and private (internal/commercial) organizations.
Prerequisites
- GitHub organization owner access for all organizations to audit
- Enterprise Cloud for cross-org visibility (recommended)
ClickOps Implementation
Step 1: Inventory Service Accounts
- Navigate to: Organization Settings -> People
- Filter by role to identify non-human accounts (look for naming patterns like
*-bot,*-mgt,*-ci,*-automation,*-svc) - For each identified service account, check: User Profile -> Organizations to see which other orgs the account belongs to
- Document all service accounts with access to more than one organization
Step 2: Isolate Service Accounts Per Organization
- For each cross-org service account, create a separate, dedicated account per organization
- Transfer repository access, team memberships, and secrets to the new per-org accounts
- Remove the cross-org account from all but one organization
- If a workflow genuinely requires cross-org access, use GitHub App installations scoped to specific repositories rather than a user account with broad org membership
Step 3: Replace User-Based Service Accounts with GitHub Apps
- Create a GitHub App for each automation use case
- GitHub Apps can be installed on specific repositories within an organization — they cannot inherently access other organizations
- Use installation tokens (short-lived, 1-hour expiry) instead of PATs
- GitHub Apps provide granular permission scoping that user accounts cannot match
Step 4: Enforce Credential Rotation Atomicity
- Minimize the window where both old and new credentials are live — set the old credential to expire within hours, not days (see Section 6.6 Step 3 for detailed incident response context)
- The Trivy Phase 2 compromise occurred because credential rotation after Phase 1 was non-atomic — attackers accessed refreshed tokens during the rotation window
- For GitHub App private keys: generate the new key, update all consumers, verify they work, THEN delete the old key — but set the old key to expire within hours, not days
Time to Complete: ~2 hours for initial audit; ~4 hours for remediation per cross-org account
Code Implementation
This control is primarily organizational — no API or Terraform automation exists for cross-org service account restriction. Use the CLI audit check (hth scan github --controls github-1.8) to identify admin members matching service account naming patterns.
Validation & Testing
- All service accounts are inventoried with org membership documented
- No service account has admin/write access to more than one organization
- Cross-org automation uses GitHub App installations, not shared user accounts
- Credential rotation procedures include atomic revocation steps
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1, CC6.3 | Logical access, role-based access |
| NIST 800-53 | AC-6(3), AC-6(5) | Least privilege - network access, privileged accounts |
| ISO 27001 | A.9.2.3 | Management of privileged access rights |
| CIS Controls | 6.8 | Define and maintain role-based access control |
2. Repository Security
2.1 Enable Branch Protection for All Critical Branches
Profile Level: L1 (Baseline) NIST 800-53: CM-3 (Configuration Change Control)
Description
Protect main, master, production, and release branches from direct pushes. Require pull requests, reviews, and status checks before merging.
Rationale
Attack Prevented: Direct malicious code injection into production
Real-World Incident:
- CodeCov Bash Uploader Compromise (April 2021): Attacker modified Codecov’s bash uploader script to exfiltrate environment variables (secrets) from thousands of CI/CD pipelines. Branch protection requiring PR reviews would have caught this modification.
ClickOps Implementation
Option A: Repository Rulesets (Recommended)
Rulesets are now the primary mechanism for branch protection, replacing legacy branch protection rules. They provide centralized governance at the organization level (see Section 2.3).
- Navigate to: Repository Settings -> Rules -> Rulesets
- Click New ruleset -> New branch ruleset
- Configure branch targeting:
main,master,release/* - Enable rules (see Section 2.3 for full details)
Option B: Legacy Branch Protection Rules
- Navigate to: Repository Settings -> Branches
- Under “Branch protection rules”, click “Add branch protection rule”
- Branch name pattern:
main(ormaster,production) - Enable these protections:
- Require a pull request before merging
- Require approvals (minimum: 1 for L1, 2 for L2)
- Dismiss stale pull request approvals when new commits are pushed
- Require status checks to pass before merging
- Select required checks (tests, security scans)
- Require branches to be up to date before merging
- Require conversation resolution before merging
- Do not allow bypassing the above settings (critical!)
- Restrict who can push to matching branches (optional: restrict to CI bot only)
- Require a pull request before merging
- Click “Create”
Repeat for all critical branches.
Time to Complete: ~10 minutes per repository
Code Implementation
Code Pack: SDK Script
from github import Github
import os
g = Github(os.environ['GITHUB_TOKEN'])
org = g.get_organization(os.environ.get('GITHUB_ORG', 'your-org'))
PROTECTED_BRANCHES = ['main', 'master', 'production', 'release']
for repo in org.get_repos():
print(f"Processing: {repo.name}")
for branch_name in PROTECTED_BRANCHES:
try:
branch = repo.get_branch(branch_name)
# Apply protection
branch.edit_protection(
strict=True,
contexts=["ci/test"],
enforce_admins=True,
dismiss_stale_reviews=True,
require_code_owner_reviews=True,
required_approving_review_count=1
)
print(f" Protected: {branch_name}")
except Exception as e:
print(f" Skipped {branch_name}: {e}")
Code Pack: Terraform
resource "github_branch_protection" "main" {
repository_id = var.repository_id
pattern = "main"
enforce_admins = true
required_pull_request_reviews {
required_approving_review_count = 1
dismiss_stale_reviews = true
}
}
Code Pack: API Script
# Enable branch protection with required reviews, dismiss stale, and enforce admins
info "3.01 Enabling branch protection on '${DEFAULT_BRANCH}'..."
RESPONSE=$(gh_put "/repos/${GITHUB_ORG}/${REPO}/branches/${DEFAULT_BRANCH}/protection" '{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null
}') || {
fail "3.01 Failed to enable branch protection"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'protected_branch.destroy'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
# NOTE: The CODEOWNERS file itself must be committed to the repository
# (e.g., .github/CODEOWNERS). This Terraform resource enforces that
# pull requests require approval from designated code owners before merge.
resource "github_branch_protection" "main_codeowners" {
repository_id = var.repository_id
pattern = "main"
required_pull_request_reviews {
require_code_owner_reviews = true
}
}
Code Pack: API Script
# Audit: Check for CODEOWNERS file in standard locations
# Note: CODEOWNERS must be committed to the repo -- cannot be created via API
FOUND=false
for LOCATION in ".github/CODEOWNERS" "CODEOWNERS" "docs/CODEOWNERS"; do
CONTENT=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/contents/${LOCATION}" 2>/dev/null) || continue
HAS_NAME=$(echo "${CONTENT}" | jq -r 'has("name")' 2>/dev/null || echo "false")
if [ "${HAS_NAME}" = "true" ]; then
pass "3.06 CODEOWNERS file found at ${LOCATION}"
FOUND=true
break
fi
done
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'protected_branch.update'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_branch_protection" "main_signed" {
repository_id = var.repository_id
pattern = "main"
require_signed_commits = true
}
Code Pack: API Script
# Audit: Check recent commits for GPG/SSH signature verification
# Note: Commit signing is per-developer; enforcement is via branch protection rules
info "3.07 Checking recent commit signatures..."
COMMITS=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/commits?per_page=10") || {
fail "3.07 Unable to retrieve commits for ${GITHUB_ORG}/${REPO}"
increment_failed
summary
exit 0
}
TOTAL=$(echo "${COMMITS}" | jq '. | length' 2>/dev/null || echo "0")
VERIFIED=$(echo "${COMMITS}" | jq '[.[] | select(.commit.verification.verified == true)] | length' 2>/dev/null || echo "0")
UNVERIFIED=$((TOTAL - VERIFIED))
info "3.07 Checked ${TOTAL} recent commits: ${VERIFIED} signed, ${UNVERIFIED} unsigned"
# Check if branch protection enforces signed commits
REPO_META=$(gh_get "/repos/${GITHUB_ORG}/${REPO}") || true
DEFAULT_BRANCH=$(echo "${REPO_META}" | jq -r '.default_branch // "main"' 2>/dev/null)
SIGNATURE_REQUIRED="false"
PROTECTION=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/branches/${DEFAULT_BRANCH}/protection/required_signatures" 2>/dev/null) && {
SIGNATURE_REQUIRED=$(echo "${PROTECTION}" | jq -r '.enabled // false' 2>/dev/null || echo "false")
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'protected_branch.update'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Validation & Testing
- Attempt to push directly to protected branch (should fail)
- Create PR without required status checks (should block merge)
- Create PR without required approvals (should block merge)
- Verify admin cannot bypass (if enforce_admins enabled)
Monitoring & Maintenance
Alert on protection changes:
Code Pack: API Script
# Query audit log for branch protection rule changes
EVENTS=$(gh_get "/orgs/${GITHUB_ORG}/audit-log?phrase=action:protected_branch&per_page=30") || {
fail "2.06 Unable to query audit log (requires admin:org scope)"
increment_failed
summary
exit 0
}
echo "${EVENTS}" | jq '[.[] | {
actor: .actor,
action: .action,
repo: .repo,
created_at: .created_at
}]'
Code Pack: Sigma Detection Rule
detection:
selection:
action|startswith: 'protected_branch.'
filter_expected:
action: 'protected_branch.create'
condition: selection and not filter_expected
fields:
- actor
- action
- org
- repo
- created_at
Maintenance:
- Monthly: Audit repositories missing branch protection
- Quarterly: Review and update required status checks
Operational Impact
| Aspect | Impact Level | Details |
|---|---|---|
| Developer Workflow | Medium | Must create PRs instead of direct push |
| Merge Speed | Low | Adds review time (offset by quality improvement) |
| Emergency Hotfixes | Medium | Still possible via PR with expedited review |
| Rollback | Easy | Delete branch protection rule |
Compliance Mappings
- SOC 2: CC8.1 (Change management)
- NIST 800-53: CM-3 (Configuration Change Control)
- ISO 27001: A.12.1.2 (Change management)
2.2 Enable Security Features: Dependabot, Code Scanning, Secret Scanning
Profile Level: L1 (Baseline)
Description
Enable GitHub’s native security features to detect vulnerabilities, secrets, and code quality issues automatically.
Rationale
Attack Prevention:
- Secret Scanning: Detects accidentally committed API keys, passwords, tokens
- Dependabot: Automatically identifies vulnerable dependencies
- Code Scanning (CodeQL): Finds security vulnerabilities in code
Real-World Incident:
- Travis CI Secret Exposure (September 2021): Secrets in logs exposed for years. GitHub Secret Scanning would have detected these tokens.
ClickOps Implementation
Step 1: Enable at Organization Level
- Organization Settings -> Code security and analysis
- Enable for all repositories:
- Dependency graph (free)
- Dependabot alerts (free)
- Dependabot security updates (free)
- Secret scanning (free for public repos; requires GitHub Secret Protection for private repos)
- Push protection (enabled by default for public repos; enable for private repos)
- Code scanning (requires Actions, free for public repos; requires GitHub Code Security for private repos)
- Enable Automatically enable for new repositories for each feature
Note: As of April 2025, GitHub Advanced Security has been split into two standalone products: GitHub Secret Protection and GitHub Code Security, now available to GitHub Team plan customers.
Step 2: Configure Per-Repository (if needed)
- Navigate to: Repository Settings -> Code security and analysis
- Enable same features
- For Code scanning, click “Set up” -> Choose “Default setup” (recommended) or “Advanced setup” for custom CodeQL configuration
Step 3: Configure Custom Secret Scanning Patterns (Enterprise)
- Navigate to: Organization Settings -> Code security -> Secret scanning
- Add custom patterns for:
- Internal API keys
- Database connection strings
- Custom tokens
- Enable “Include in push protection” for each custom pattern (GA since August 2025)
Time to Complete: ~15 minutes
Code Implementation
Code Pack: Terraform
resource "github_repository" "how_to_harden_secrets" {
name = var.repository_name
security_and_analysis {
secret_scanning {
status = "enabled"
}
secret_scanning_push_protection {
status = "enabled"
}
}
}
Code Pack: API Script
# Enable secret scanning and push protection on the repository
info "2.01 Enabling secret scanning and push protection..."
RESPONSE=$(gh_patch "/repos/${GITHUB_ORG}/${REPO}" '{
"security_and_analysis": {
"secret_scanning": { "status": "enabled" },
"secret_scanning_push_protection": { "status": "enabled" }
}
}') || {
fail "2.01 Failed to enable secret scanning -- may require GHAS license"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_secret_scanning.disable'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_repository" "how_to_harden_dependabot" {
name = var.repository_name
vulnerability_alerts = true
}
Code Pack: API Script
# Enable Dependabot vulnerability alerts and security updates
info "2.02 Enabling vulnerability alerts and Dependabot security updates..."
curl -sf -X PUT "${GH_API}/repos/${GITHUB_ORG}/${REPO}/vulnerability-alerts" \
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" || {
fail "2.02 Failed to enable vulnerability alerts"
increment_failed
summary
exit 0
}
curl -sf -X PUT "${GH_API}/repos/${GITHUB_ORG}/${REPO}/automated-security-fixes" \
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" || {
fail "2.02 Failed to enable Dependabot security updates"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_vulnerability_alert.dismiss'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_repository" "how_to_harden_non_provider" {
name = var.repository_name
security_and_analysis {
secret_scanning {
status = "enabled"
}
secret_scanning_non_provider_patterns {
status = "enabled"
}
}
}
Code Pack: API Script
# Enable secret scanning for non-provider patterns (generic secrets, passwords, keys)
info "2.03 Enabling non-provider pattern scanning..."
RESPONSE=$(gh_patch "/repos/${GITHUB_ORG}/${REPO}" '{
"security_and_analysis": {
"secret_scanning_non_provider_patterns": { "status": "enabled" }
}
}') || {
fail "2.03 Failed to enable non-provider patterns -- may require GHAS license"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection_pattern_delete:
action: 'repository_secret_scanning_custom_pattern.delete'
selection_scanning_disable:
action: 'repository_secret_scanning.disable'
condition: selection_pattern_delete or selection_scanning_disable
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_repository" "how_to_harden_validity" {
name = var.repository_name
security_and_analysis {
secret_scanning {
status = "enabled"
}
secret_scanning_validity_checks {
status = "enabled"
}
}
}
Code Pack: API Script
# Enable secret scanning validity checks to verify if detected secrets are still active
info "2.04 Enabling secret scanning validity checks..."
RESPONSE=$(gh_patch "/repos/${GITHUB_ORG}/${REPO}" '{
"security_and_analysis": {
"secret_scanning_validity_checks": { "status": "enabled" }
}
}') || {
fail "2.04 Failed to enable validity checks -- may require GHAS license"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_secret_scanning.disable'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_repository" "how_to_harden_code_scanning" {
name = var.repository_name
security_and_analysis {
advanced_security {
status = "enabled"
}
}
}
Code Pack: API Script
# Enable CodeQL code scanning with the default query suite
info "3.05 Enabling code scanning default setup..."
RESPONSE=$(gh_patch "/repos/${GITHUB_ORG}/${REPO}/code-scanning/default-setup" '{
"state": "configured",
"query_suite": "default"
}') || {
fail "3.05 Failed to enable code scanning -- may require GHAS license"
increment_failed
summary
exit 0
}
Code Pack: CLI Script
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '0 0 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_code_scanning_default_setup.disable'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Secret Scanning Push Protection
L2 Enhancement: Enable push protection to block commits containing secrets. This prevents secrets from ever entering Git history (better than post-commit detection). Push protection is now enabled by default for all public repositories. For private repositories, enable it via the organization settings or API (see Code Pack above). For delegated bypass and custom patterns, see Section 2.6.
L2 Enhancement: CodeQL Advanced Configuration
For organizations requiring deeper code analysis, configure CodeQL with custom query suites and scheduled scanning. See the advanced CodeQL workflow in the Code Pack above.
CodeQL Configuration Options:
security-extended– Includes additional security queries beyond defaultsecurity-and-quality– Full security plus code quality queries- Custom query packs for organization-specific patterns
Monitoring Alerts
Code Pack: API Script
# List critical/high severity alerts
gh api /orgs/{org}/dependabot/alerts --jq '.[] | select(.severity == "critical" or .severity == "high") | {repo: .repository.name, package: .security_advisory.package.name, severity: .severity}'
# List active secret alerts
gh api /orgs/{org}/secret-scanning/alerts?state=open --jq '.[] | {repo: .repository.name, secret_type: .secret_type, created_at: .created_at}'
Compliance Mappings
- SOC 2: CC7.2 (System monitoring)
- NIST 800-53: RA-5 (Vulnerability scanning), SA-11 (Security testing)
- ISO 27001: A.12.6.1 (Technical vulnerability management)
2.3 Configure Repository Rulesets
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud/Server NIST 800-53: CM-3 (Configuration Change Control) CIS Controls: 16.9
Description
Configure organization-wide repository rulesets to enforce consistent branch protection across all repositories. Rulesets provide centralized governance that cannot be overridden at the repository level, unlike individual branch protection rules.
Rationale
Why This Matters:
- Branch protection rules are configured per-repository and can be modified by repository admins
- Rulesets are managed at the organization level and enforce consistent policies across all repositories
- Reduces configuration drift and ensures no repository is accidentally unprotected
ClickOps Implementation
Step 1: Create Organization Ruleset
- Navigate to: Organization Settings -> Repository -> Rulesets
- Click New ruleset -> New branch ruleset
Step 2: Configure Ruleset
- Name:
Production Branch Protection - Enforcement status: Active
- Target repositories: All or selected repositories
- Branch targeting: Include
main,master,release/*
Step 3: Configure Rules
- Enable:
- Restrict deletions
- Require pull request (with required approvals and code owner review)
- Require signed commits
- Require status checks to pass
- Require code scanning results
- Require linear history (optional – prevents merge commits)
- Block force pushes
- Configure bypass list (limit to emergency access only)
- Tag Protection via Repository Rules:
- Legacy tag protection rules are deprecated – use rulesets instead
- In the same ruleset, add a Tag ruleset targeting
v*andrelease-*patterns - Enable: Restrict creations, Restrict deletions, Block force pushes
- This prevents unauthorized release tagging and protects release integrity
Code Implementation
Code Pack: Terraform
resource "github_organization_ruleset" "production_branch_protection" {
name = "Production Branch Protection"
target = "branch"
enforcement = "active"
conditions {
ref_name {
include = ["~DEFAULT_BRANCH", "refs/heads/release/*"]
exclude = []
}
}
rules {
deletion = true
non_fast_forward = true
required_signatures = true
pull_request {
required_approving_review_count = 2
dismiss_stale_reviews_on_push = true
require_code_owner_review = true
require_last_push_approval = true
}
}
}
Code Pack: API Script
# Create organization ruleset via API
info "2.05 Creating production branch protection ruleset..."
RESPONSE=$(gh_post "/orgs/${GITHUB_ORG}/rulesets" '{
"name": "Production Branch Protection",
"enforcement": "active",
"target": "branch",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/master", "refs/heads/release/*"],
"exclude": []
}
},
"rules": [
{"type": "deletion"},
{"type": "non_fast_forward"},
{"type": "pull_request", "parameters": {"required_approving_review_count": 2, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": true}},
{"type": "required_signatures"}
]
}') || {
fail "2.05 Failed to create ruleset -- may already exist or require admin permissions"
increment_failed
summary
exit 0
}
# List existing rulesets
info "2.05 Current rulesets:"
gh_get "/orgs/${GITHUB_ORG}/rulesets" \
| jq '.[] | {name: .name, enforcement: .enforcement}'
Validation & Testing
- Verify ruleset is active and applies to target branches
- Attempt direct push to protected branch (should fail)
- Verify bypass list is limited to emergency accounts only
- Test that new repositories automatically inherit rulesets
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC8.1 | Change management |
| NIST 800-53 | CM-3 | Configuration change control |
| ISO 27001 | A.12.1.2 | Change management |
| CIS Controls | 16.9 | Secure application development |
2.4 Enforce Commit Signing
Profile Level: L2 (Hardened) NIST 800-53: SI-7 (Software, Firmware, and Information Integrity) CIS Controls: 16.9
Description
Require cryptographically signed commits to verify commit authenticity and prevent tampering. Commit signing provides non-repudiation and ensures commits are genuinely from the claimed author.
Rationale
Attack Prevented: Commit spoofing – Git allows anyone to set any name/email in commit metadata. Without signing, an attacker with push access can create commits that appear to be from trusted developers.
Real-World Risk: The Fake Dependabot Commits attack (July 2023) used stolen PATs to inject malicious commits disguised as Dependabot contributions. Signed commit requirements would have flagged these as unverified.
ClickOps Implementation
Step 1: Configure Vigilant Mode
- Navigate to: User Settings -> SSH and GPG keys
- Enable Flag unsigned commits as unverified
Step 2: Require Signed Commits in Branch Protection
- Navigate to: Repository Settings -> Branches -> Edit rule
- Enable Require signed commits
Step 3: Enforce via Organization Ruleset (recommended)
- Navigate to: Organization Settings -> Repository -> Rulesets
- Edit production ruleset
- Enable Require signed commits rule
Supported Signing Methods:
- GPG keys
- SSH keys (recommended for ease of use)
- S/MIME certificates
- Sigstore Gitsign (keyless, OIDC-based — recommended for teams adopting zero-trust)
Gitsign (Sigstore Keyless Signing):
Gitsign eliminates key management entirely by using Sigstore’s Fulcio CA to issue short-lived (~10 min) X.509 certificates tied to developer OIDC identity (GitHub, Google, or Microsoft login). Signatures are recorded in Sigstore’s Rekor transparency log for tamperproof audit.
Developer setup: Install Gitsign (brew install gitsign), then configure git:
git config --local gpg.x509.program gitsigngit config --local gpg.format x509git config --local commit.gpgsign true- Signing a commit opens a browser for OIDC authentication; use
gitsign-credential-cacheto persist credentials for their 10-minute lifetime
GitHub Limitation: GitHub’s “Require signed commits” branch protection rule does not recognize Gitsign/Sigstore signatures — it only supports GPG, SSH, and S/MIME. Gitsign-signed commits appear as “Unverified” in GitHub’s UI because Sigstore’s CA root is not in GitHub’s trust roots, and Gitsign’s ephemeral certificates expire before GitHub’s standard X.509 verification checks them. This is despite GitHub using Sigstore internally for Artifact Attestations (Section 3.5).
Workaround — Custom Verification via GitHub Actions: Since GitHub’s branch protection cannot verify Gitsign signatures, enforce verification with a required status check workflow that runs gitsign verify --certificate-oidc-issuer=ISSUER --certificate-identity-regexp=PATTERN against each commit in a PR. Combined with branch protection’s “Require status checks to pass,” this effectively replaces the native signed commits check for Gitsign users.
CI/CD Signing with Gitsign: Set GITSIGN_TOKEN_PROVIDER=github-actions in workflow environment to use the workflow’s OIDC token for signing — no browser required. The signing identity becomes the workflow identity (e.g., repo:org/repo:ref:refs/heads/main).
Code Implementation
Developer Setup (local git config):
Code Pack: CLI Script
# Configure Git to sign commits with SSH key
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
# Configure Git to sign commits with GPG key
git config --global user.signingkey YOUR_GPG_KEY_ID
git config --global commit.gpgsign true
# Verify a signed commit
git log --show-signature -1
Code Pack: Terraform
resource "github_branch_protection" "main_signed" {
repository_id = var.repository_id
pattern = "main"
require_signed_commits = true
}
Code Pack: API Script
# Audit: Check recent commits for GPG/SSH signature verification
# Note: Commit signing is per-developer; enforcement is via branch protection rules
info "3.07 Checking recent commit signatures..."
COMMITS=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/commits?per_page=10") || {
fail "3.07 Unable to retrieve commits for ${GITHUB_ORG}/${REPO}"
increment_failed
summary
exit 0
}
TOTAL=$(echo "${COMMITS}" | jq '. | length' 2>/dev/null || echo "0")
VERIFIED=$(echo "${COMMITS}" | jq '[.[] | select(.commit.verification.verified == true)] | length' 2>/dev/null || echo "0")
UNVERIFIED=$((TOTAL - VERIFIED))
info "3.07 Checked ${TOTAL} recent commits: ${VERIFIED} signed, ${UNVERIFIED} unsigned"
# Check if branch protection enforces signed commits
REPO_META=$(gh_get "/repos/${GITHUB_ORG}/${REPO}") || true
DEFAULT_BRANCH=$(echo "${REPO_META}" | jq -r '.default_branch // "main"' 2>/dev/null)
SIGNATURE_REQUIRED="false"
PROTECTION=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/branches/${DEFAULT_BRANCH}/protection/required_signatures" 2>/dev/null) && {
SIGNATURE_REQUIRED=$(echo "${PROTECTION}" | jq -r '.enabled // false' 2>/dev/null || echo "false")
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'protected_branch.update'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Validation & Testing
- Create an unsigned commit and attempt to push (should fail if required)
- Create a signed commit and verify it shows as “Verified” in GitHub UI
- Verify vigilant mode flags unsigned commits from other contributors
- If using Gitsign: verify signature with
gitsign verifyand confirm Rekor log inclusion
Operational Impact
| Aspect | Impact Level | Details |
|---|---|---|
| Developer Setup | Medium | Developers must configure GPG, SSH, or Gitsign |
| CI/CD Commits | Medium | Bot accounts need signing keys or Gitsign with OIDC token provider |
| Onboarding | Medium | New developers must set up commit signing |
| Rollback | Easy | Disable in branch protection or ruleset |
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC8.1 | Change management - code integrity |
| NIST 800-53 | SI-7 | Software and information integrity |
| ISO 27001 | A.14.2.7 | Outsourced development integrity |
| CIS Controls | 16.9 | Secure application development |
2.5 Configure Push Rules in Repository Rulesets
Profile Level: L2 (Hardened) Requires: GitHub Team (private/internal repos) or Enterprise Cloud NIST 800-53: CM-3, SI-7 CIS Controls: 16.9
Description
Configure push rules within repository rulesets to restrict file types, file sizes, and file paths across the organization. Unlike branch protection rules, push rules apply to the entire fork network, ensuring every entry point to the repository is protected regardless of where a push originates.
Rationale
Attack Prevented: Binary injection, large file denial-of-service, unauthorized workflow modifications
Why This Matters:
- Push rules block dangerous file types (executables, compiled binaries) from entering repositories
- File size limits prevent repository bloat and potential denial-of-service via large files
- File path restrictions prevent unauthorized modification of CI/CD workflows (
.github/workflows/) - Fork network enforcement closes a common bypass vector where attackers push to forks
ClickOps Implementation
Step 1: Create Push Ruleset
- Navigate to: Organization Settings -> Repository -> Rulesets
- Click New ruleset -> New push ruleset
Step 2: Configure Targets
- Name:
File Protection Push Rules - Enforcement status: Active
- Target repositories: All repositories or selected
- Configure bypass list (limit to org admins only)
Step 3: Add Push Rules
- Restrict file extensions: Add
.exe,.dll,.so,.dylib,.bin,.jar,.war,.class - Restrict file size: Set maximum to 10 MB (adjust per your needs)
- Restrict file paths: Add
.github/workflows/**to prevent unauthorized workflow changes
Time to Complete: ~10 minutes
Code Implementation
Code Pack: API Script
# Enable private vulnerability reporting on a single repository
gh api --method PUT \
"/repos/${GITHUB_ORG}/${REPO}/private-vulnerability-reporting"
# Enable private vulnerability reporting for all repositories in the organization
gh api --method PUT \
"/orgs/${GITHUB_ORG}/private-vulnerability-reporting"
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_vulnerability_alert.disable'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Validation & Testing
- Attempt to push an
.exefile (should be blocked) - Attempt to push a file larger than the size limit (should be blocked)
- Attempt to push workflow changes from a fork (should be blocked if path-restricted)
- Verify bypass actors can still push restricted content when needed
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC8.1 | Change management |
| NIST 800-53 | CM-3, SI-7 | Configuration change control, integrity |
| ISO 27001 | A.12.1.2 | Change management |
| CIS Controls | 16.9 | Secure application development |
2.6 Enable Secret Scanning Delegated Bypass
Profile Level: L2 (Hardened) Requires: GitHub Advanced Security (or GitHub Secret Protection standalone) NIST 800-53: SA-11, IA-5(7)
Description
Configure delegated bypass for secret scanning push protection to require security team approval before developers can bypass push protection blocks. Additionally, add custom patterns to push protection for organization-specific secrets. Push protection is now enabled by default for all public repositories.
Rationale
Attack Prevented: Accidental secret leakage, unauthorized bypass of security controls
Why This Matters:
- By default, developers can self-approve bypasses of push protection with a reason (false positive, used in tests, will fix later)
- Delegated bypass requires a designated security reviewer to approve each bypass request
- Custom patterns extend push protection beyond the 200+ default patterns to cover organization-specific secrets
- Configuring custom patterns in push protection is now GA (August 2025)
ClickOps Implementation
Step 1: Enable Push Protection (if not already enabled)
- Navigate to: Organization Settings -> Code security and analysis
- Under “Secret scanning”:
- Enable Secret scanning for all repositories
- Enable Push protection for all repositories
Step 2: Configure Delegated Bypass
- Navigate to: Organization Settings -> Code security -> Global settings
- Under “Push protection”:
- Select “Require approval to bypass push protection”
- Add your security team as designated reviewers
- Click Save
Step 3: Add Custom Patterns to Push Protection
- Navigate to: Organization Settings -> Code security -> Secret scanning
- Click New pattern
- Define pattern:
- Name: e.g.,
Internal API Key - Secret format: regex pattern matching your internal key format
- Enable “Include in push protection”
- Name: e.g.,
- Test pattern with sample data
- Click Publish pattern
Time to Complete: ~15 minutes
Code Implementation
Code Pack: API Script
# Audit push protection bypass events via secret scanning alerts
info "2.10 Listing push protection bypass alerts..."
ALERTS=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/secret-scanning/alerts?state=open&per_page=100") || {
warn "2.10 Unable to list secret scanning alerts (may require GHAS license)"
}
if [ -n "${ALERTS}" ]; then
BYPASS_COUNT=$(echo "${ALERTS}" | jq '[.[] | select(.push_protection_bypassed == true)] | length')
if [ "${BYPASS_COUNT}" -gt "0" ]; then
warn "2.10 ${BYPASS_COUNT} alert(s) with push protection bypass detected"
echo "${ALERTS}" | jq -r '.[] | select(.push_protection_bypassed == true) | "\(.secret_type) | bypassed by: \(.push_protection_bypassed_by.login // "unknown") | \(.created_at)"'
else
pass "2.10 No push protection bypasses detected"
fi
fi
# Check org-level security configuration for push protection
info "2.10 Checking organization code security configuration..."
ORG_SECURITY=$(gh_get "/orgs/${GITHUB_ORG}/code-security/configurations") || {
warn "2.10 Unable to retrieve org security configurations"
}
if [ -n "${ORG_SECURITY}" ]; then
echo "${ORG_SECURITY}" | jq '.[] | {name: .name, secret_scanning_push_protection: .secret_scanning_push_protection}'
fi
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'secret_scanning_push_protection.bypass'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
- push_protection_bypass_reason
Validation & Testing
- Attempt to push a commit containing a known secret (should be blocked)
- Attempt to bypass push protection (should require reviewer approval if delegated bypass enabled)
- Verify custom patterns detect organization-specific secrets
- Confirm bypass alerts are visible in the Security tab
Monitoring & Maintenance
Maintenance schedule:
- Weekly: Review push protection bypass requests and approvals
- Monthly: Audit custom patterns for accuracy (false positive rate)
- Quarterly: Review bypass trends and update patterns
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC7.2, CC6.1 | System monitoring, logical access |
| NIST 800-53 | SA-11, IA-5(7) | Security testing, credential management |
| ISO 27001 | A.12.6.1 | Technical vulnerability management |
3. GitHub Actions & CI/CD Security
3.1 Restrict Third-Party GitHub Actions to Verified Creators Only
Profile Level: L1 (Baseline) SLSA: Build L2+ requirement
Description
Prevent use of arbitrary third-party Actions by restricting to GitHub-verified creators and specific allow-listed actions. This limits supply chain risk from compromised or malicious Actions.
Rationale
Attack Vector: Malicious GitHub Actions can:
- Exfiltrate secrets from workflow environment
- Modify build artifacts to inject backdoors
- Steal source code
Real-World Risk:
- Dependency Confusion: Attackers create Actions with similar names to popular ones
- Typosquatting:
actions/checkoutvsactions/check-out(malicious) - Compromised Maintainer: Legitimate Action author’s account taken over
- Tag Poisoning: trivy-action (March 2026) had 75 of 76 tags poisoned with credential-stealing malware; tj-actions/changed-files (March 2025, CVE-2025-30066) had all tags rewritten, affecting 23,000+ repos. See Section 3.10 for detection controls.
- Imposter Commits: GitHub’s fork network shares Git objects between parent and fork repos. A commit pushed to a fork of an allowed action can be referenced via the parent’s path, bypassing allow-list restrictions. Use
clank(Section 3.10) to verify pinned SHAs originate from parent branches.
ClickOps Implementation
Step 1: Set Enterprise Actions Policy (Enterprise Cloud/Server)
- Navigate to: Enterprise Settings -> Policies -> Actions
- Configure allowed actions:
- Allow enterprise and select non-enterprise actions (recommended)
- Specify allowed patterns:
github/*,actions/*
- Restrict to verified creators if possible
Step 2: Set Organization Action Policy
- Organization Settings -> Actions -> General
- Under “Actions permissions”:
- Select “Allow [org-name], and select non-[org-name], actions and reusable workflows”
- Under “Allow specified actions and reusable workflows”:
- Allow actions created by GitHub (GitHub-verified)
- Allow actions by Marketplace verified creators
- Add specific allow-listed actions (see allowed list in Code Pack below)
- Click “Save”
Code Pack: CLI Script
# Tier 1 - GitHub Official (Always Allow)
# actions/*
# github/*
# Tier 2 - Verified Cloud Vendors
# aws-actions/*
# azure/*
# google-github-actions/*
# hashicorp/*
# docker/*
# Tier 3 - Common Verified Actions
# codecov/codecov-action@*
# snyk/actions/*
# sonatype-nexus-community/*
# Configure via gh CLI:
gh api --method PUT /orgs/{org}/actions/permissions/selected-actions \
-f 'github_owned_allowed=true' \
-f 'verified_allowed=true' \
-f 'patterns_allowed[]=actions/*' \
-f 'patterns_allowed[]=github/*' \
-f 'patterns_allowed[]=aws-actions/*' \
-f 'patterns_allowed[]=azure/*' \
-f 'patterns_allowed[]=google-github-actions/*' \
-f 'patterns_allowed[]=hashicorp/*' \
-f 'patterns_allowed[]=docker/*'
Time to Complete: ~5 minutes
Code Implementation
Code Pack: Terraform
resource "github_actions_repository_permissions" "how_to_harden_actions" {
repository = var.repository_name
enabled = true
allowed_actions = "selected"
allowed_actions_config {
github_owned_allowed = true
verified_allowed = true
}
}
Code Pack: API Script
# Restrict GitHub Actions to only selected (verified) creators
info "3.02 Restricting Actions to selected creators..."
RESPONSE=$(gh_put "/repos/${GITHUB_ORG}/${REPO}/actions/permissions" '{
"enabled": true,
"allowed_actions": "selected"
}') || {
fail "3.02 Failed to restrict Actions permissions"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repo.actions_enabled'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
# NOTE: SHA pinning is enforced at the workflow file level, not via a dedicated
# Terraform resource. This control restricts allowed actions to "selected" so
# that only explicitly approved actions (pinned to SHA in workflow YAML) can run.
resource "github_actions_repository_permissions" "how_to_harden_sha_pinning" {
repository = var.repository_name
enabled = true
allowed_actions = "selected"
}
Code Pack: API Script
# Audit: Check workflow files for actions not pinned to full SHA
# Note: SHA pinning cannot be enforced via API -- workflows must be manually updated
# Recommended tool: npx pin-github-action .github/workflows/*.yml
info "3.03 Retrieving workflow files..."
WORKFLOWS=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/contents/.github/workflows" 2>/dev/null) || {
warn "3.03 No .github/workflows directory found -- no workflows to audit"
increment_applied
summary
exit 0
}
WORKFLOW_COUNT=$(echo "${WORKFLOWS}" | jq '. | length' 2>/dev/null || echo "0")
info "3.03 Found ${WORKFLOW_COUNT} workflow file(s)"
if [ "${WORKFLOW_COUNT}" = "0" ]; then
pass "3.03 No workflow files found -- nothing to pin"
increment_applied
summary
exit 0
fi
# Check each workflow for tag-based action references (not SHA-pinned)
UNPINNED=0
for NAME in $(echo "${WORKFLOWS}" | jq -r '.[].name' 2>/dev/null); do
CONTENT=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/contents/.github/workflows/${NAME}" \
| jq -r '.content // empty' 2>/dev/null | base64 -d 2>/dev/null || true)
if [ -n "${CONTENT}" ]; then
# Look for uses: owner/action@v* or @tag patterns (not 40-char SHA)
TAG_REFS=$(echo "${CONTENT}" | grep -cE 'uses:\s+\S+@v[0-9]' 2>/dev/null || true)
if [ "${TAG_REFS}" -gt 0 ] 2>/dev/null; then
warn "3.03 ${NAME}: ${TAG_REFS} action(s) pinned to tag instead of SHA"
UNPINNED=$((UNPINNED + TAG_REFS))
fi
fi
done
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repo.actions_enabled'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Best Practices for Action Selection
Tier 1 - Safest (Always Allow):
actions/*- GitHub official actionsgithub/*- GitHub-maintained
Tier 2 - Verified Vendors:
aws-actions/*- AWS officialazure/*- Microsoft Azuregoogle-github-actions/*- Google Cloudhashicorp/*- HashiCorp (Terraform)docker/*- Docker official
Tier 3 - Vet Before Allowing:
- Popular community actions with >10K stars
- Actions you’ve reviewed source code for
- Pin to specific commit SHA (not tag/branch)
Pin Actions to Commit SHA (L2 Enhancement)
Why: Tags and branches can be moved to point to malicious code. Commit SHAs are immutable.
Code Pack: CLI Script
# Bad: Tag can be repointed
- uses: actions/checkout@v3 # Tag can be repointed
# Good: SHA is immutable
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # SHA
Automation to pin SHAs:
Code Pack: API Script
# Use https://github.com/mheap/pin-github-action
npx pin-github-action .github/workflows/*.yml
Automated SHA updates with Dependabot:
Code Pack: CLI Script
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Monitoring & Maintenance
Alert on unapproved Action usage:
Code Pack: API Script
# Scan all org repos for non-allowed actions
info "3.08 Scanning org workflows for unapproved actions..."
REPOS=$(gh_get "/orgs/${GITHUB_ORG}/repos?per_page=100" | jq -r '.[].name') || {
fail "3.08 Unable to list repositories"
increment_failed
summary
exit 0
}
for repo in ${REPOS}; do
WORKFLOWS=$(gh_get "/repos/${GITHUB_ORG}/${repo}/actions/workflows" \
| jq -r '.workflows[].path' 2>/dev/null) || continue
for workflow in ${WORKFLOWS}; do
CONTENT=$(gh_get "/repos/${GITHUB_ORG}/${repo}/contents/${workflow}" \
| jq -r '.content' 2>/dev/null | base64 -d 2>/dev/null) || continue
UNAPPROVED=$(echo "${CONTENT}" | grep -oP 'uses:\s+\K[^\s]+' \
| grep -v '^actions/' | grep -v '^github/' || true)
if [ -n "${UNAPPROVED}" ]; then
warn "3.08 ${repo}/${workflow}: ${UNAPPROVED}"
fi
done
done
Operational Impact
| Aspect | Impact Level | Details |
|---|---|---|
| Developer Workflow | Medium | Must request approval for new Actions |
| Build Speed | None | No performance impact |
| Maintenance | Medium | Must review/approve new Action requests |
| Rollback | Easy | Change policy to allow all actions |
Compliance Mappings
- SLSA: Build L2 (Build as Code)
- NIST 800-53: SA-12 (Supply Chain Protection)
- SOC 2: CC9.2 (Third-party management)
3.2 Use Least-Privilege Workflow Permissions
Profile Level: L1 (Baseline) SLSA: Build L2 requirement
Description
Set GitHub Actions GITHUB_TOKEN permissions to read-only by default. Grant write permissions only when explicitly needed per workflow.
Rationale
Default Risk: By default, GITHUB_TOKEN has write access to repository contents, packages, pull requests, etc. Compromised workflow can modify code.
Least Privilege: Start with no permissions, add only what’s needed.
ClickOps Implementation
Step 1: Set Organization Default
- Organization Settings -> Actions -> General
- Under “Workflow permissions”:
- Select “Read repository contents and packages permissions” (read-only)
- Do NOT check “Allow GitHub Actions to create and approve pull requests”
- Click “Save”
Step 2: Configure at Repository Level
- Navigate to: Repository Settings -> Actions -> General
- Set Workflow permissions to Read repository contents
- Disable Allow GitHub Actions to create and approve pull requests
Step 3: Per-Workflow Explicit Permissions
In each workflow file, explicitly declare required permissions. See the workflow template in the Code Pack below.
Time to Complete: ~5 minutes org-wide + per-workflow updates
Code Implementation
Code Pack: Terraform
# NOTE: The github_actions_organization_permissions resource controls which
# Actions are allowed to run across the entire organization. This restricts
# all repositories to only GitHub-owned and Marketplace-verified actions.
resource "github_actions_organization_permissions" "hardened" {
allowed_actions = "selected"
enabled_repositories = "all"
allowed_actions_config {
github_owned_allowed = true
verified_allowed = true
}
}
Code Pack: API Script
# Restrict organization-level GitHub Actions to selected (verified) creators only
info "3.04 Restricting org-level Actions to selected creators..."
RESPONSE=$(gh_put "/orgs/${GITHUB_ORG}/actions/permissions" '{
"enabled_repositories": "all",
"allowed_actions": "selected"
}') || {
fail "3.04 Failed to restrict org Actions permissions"
increment_failed
summary
exit 0
}
Code Pack: CLI Script
name: CI
on: [push, pull_request]
permissions:
contents: read # Read code
pull-requests: write # Comment on PRs (if needed)
# Omit permissions not needed
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- run: npm test
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'org.actions_enabled'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Common Permission Combinations
Code Pack: CLI Script
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions:
contents: read # Read code
pull-requests: write # Comment on PRs (if needed)
# Omit permissions not needed
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- run: npm test
permissions:
contents: read
permissions:
contents: read
pull-requests: write
statuses: write
permissions:
contents: write # Create releases
packages: write # Publish to GitHub Packages
id-token: write # OIDC token for signing
permissions:
contents: read
security-events: write # Upload SARIF results
Monitoring
Audit workflows with excessive permissions:
Code Pack: API Script
# Find workflows with 'write-all' or missing permissions
find .github/workflows -name "*.yml" -exec grep -L "permissions:" {} \;
Compliance Mappings
- SLSA: Build L2 (Least privilege)
- NIST 800-53: AC-6 (Least Privilege)
- SOC 2: CC6.2
3.3 Require Workflow Approval for First-Time Contributors
Profile Level: L2 (Hardened)
Description
Require manual approval before running workflows triggered by first-time contributors. Prevents malicious PR attacks that exfiltrate secrets.
Rationale
Attack: Attacker forks your public repo, modifies workflow to exfiltrate secrets, opens PR. Workflow runs automatically and steals ${{ secrets }}.
Prevention: Require maintainer to review and approve workflow runs from new contributors. For deeper pull_request_target workflow hardening patterns (split-workflow, artifact handoff, expression injection prevention), see Section 3.8.
ClickOps Implementation
- Repository Settings -> Actions -> General
- Under “Fork pull request workflows from outside collaborators”:
- Select “Require approval for first-time contributors” (L2)
- Or “Require approval for all outside collaborators” (L3)
- Save
Code Implementation
Code Pack: API Script
# This setting is per-repository, configured via UI
# Can be enforced via organization policy requiring repos to enable it
Compliance Mappings
- SLSA: Build L2 (Source code integrity)
- SOC 2: CC6.1 (Logical access)
3.4 Configure Self-Hosted Runner Security
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud/Server (for runner groups) NIST 800-53: CM-6 (Configuration Settings) CIS Controls: 4.1
Description
Secure self-hosted runners to prevent compromise of build environment. Self-hosted runners execute untrusted workflow code and have access to secrets, network resources, and potentially other systems on the host.
Rationale
Why This Matters:
- Self-hosted runners persist between jobs (unlike GitHub-hosted runners)
- A compromised runner can steal secrets from subsequent jobs
- Runners on the corporate network can pivot to internal systems
- Public repositories should NEVER use self-hosted runners (anyone can trigger workflow runs)
ClickOps Implementation
Step 1: Use Ephemeral Runners (Critical)
- Use ephemeral runners (new VM per job) – this is the single most impactful security measure
- Configure runners with
--ephemeralflag so they deregister after one job - Never run on controller/sensitive systems
- Use dedicated runner network segment with firewall rules
- Why ephemeral matters: In the PyTorch supply chain attack, attackers persisted on non-ephemeral runners between jobs, accessing secrets from subsequent workflow runs
Step 2: Configure Runner Groups
- Navigate to: Organization Settings -> Actions -> Runner groups
- Create runner groups for different trust levels:
production-runners– only for deployment workflows from trusted reposgeneral-runners– for CI/CD from internal repositoriespublic-runners– isolated runners with no secrets for public fork builds
- Restrict which repositories can use each group
Step 3: Configure Runner Labels
- Use labels to route jobs to appropriate runners
- Production deployments: dedicated secure runners
- Public fork builds: isolated runners with no secrets access
Code Implementation
Code Pack: API Script
# List runner groups
info "3.09 Listing runner groups for ${GITHUB_ORG}..."
GROUPS=$(gh_get "/orgs/${GITHUB_ORG}/actions/runner-groups") || {
fail "3.09 Unable to retrieve runner groups"
increment_failed
summary
exit 0
}
echo "${GROUPS}" | jq '.runner_groups[] | {name: .name, id: .id, visibility: .visibility}'
# Create a runner group with restricted repository access
info "3.09 Creating production runner group..."
gh_post "/orgs/${GITHUB_ORG}/actions/runner-groups" '{
"name": "production-runners",
"visibility": "selected",
"allows_public_repositories": false
}' || {
warn "3.09 Failed to create runner group -- may already exist"
}
# List runners in a group
GROUP_ID=$(echo "${GROUPS}" | jq -r '.runner_groups[] | select(.name == "production-runners") | .id')
if [ -n "${GROUP_ID}" ]; then
gh_get "/orgs/${GITHUB_ORG}/actions/runner-groups/${GROUP_ID}/runners" \
| jq '.runners[] | {name: .name, status: .status}'
fi
Example ephemeral runner configuration:
Code Pack: CLI Script
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: [self-hosted, production, ephemeral]
environment: production
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- run: ./deploy.sh
Validation & Testing
- Verify ephemeral runners are destroyed after each job
- Test that public repos cannot access production runner groups
- Verify network segmentation between runner groups
- Confirm secrets are not accessible from public runner groups
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access to compute resources |
| NIST 800-53 | CM-6 | Configuration settings |
| ISO 27001 | A.12.1.4 | Separation of development, testing, and operational environments |
| CIS Controls | 4.1 | Secure configuration of enterprise assets |
3.5 Generate and Verify Artifact Attestations
Profile Level: L2 (Hardened) SLSA: Build L2 (L3 with reusable workflows) NIST 800-53: SI-7, SA-12
Description
Generate cryptographically signed build provenance attestations for CI/CD artifacts using GitHub’s artifact attestation feature (GA May 2024). Attestations use Sigstore to create unforgeable links between artifacts and the workflows that built them. This achieves SLSA v1.0 Build Level 2 by default, and Build Level 3 when combined with reusable workflows that isolate the build process.
Rationale
Attack Prevented: Supply chain compromise, artifact tampering, build system manipulation
Real-World Incident:
- SolarWinds SUNBURST (2020): Attackers modified the build system to inject a backdoor into signed software updates affecting 18,000+ organizations. Artifact attestations with verified build provenance would have detected that the build did not originate from the expected workflow.
- tj-actions/changed-files (March 2025): Compromised GitHub Action affected 23,000+ repositories. Artifact attestations would have allowed consumers to verify the provenance of artifacts built with this action.
Why This Matters: Attestations provide a cryptographic chain of custody from source code to built artifact. Consumers can verify that artifacts were built by trusted workflows from trusted repositories.
Prerequisites
- GitHub Actions enabled
- Repository must be public, or organization must have GitHub Enterprise Cloud
ClickOps Implementation
No GUI configuration required. Artifact attestations are implemented via workflow YAML using the actions/attest-build-provenance action.
Step 1: Add Attestation to Build Workflow
- Edit your build workflow file (e.g.,
.github/workflows/build.yml) - Add required permissions:
id-token: write,contents: read,attestations: write - Add the
actions/attest-build-provenancestep after your build step - See the workflow template in the Code Pack below
Step 2: Verify Attestations
- After a build completes, use the GitHub CLI to verify:
- For binaries:
gh attestation verify PATH/TO/ARTIFACT -R OWNER/REPO - For container images:
gh attestation verify oci://ghcr.io/OWNER/IMAGE:TAG -R OWNER/REPO
- For binaries:
Step 3: Achieve SLSA Build Level 3 (optional)
- Move build logic into a reusable workflow (
.github/workflows/build-reusable.yml) - Call the reusable workflow from your main workflow
- The reusable workflow provides isolation between the calling workflow and the build process
Time to Complete: ~15 minutes per workflow
Code Implementation
Code Pack: API Script
# Create a hardened code security configuration
gh api --method POST \
"/orgs/${GITHUB_ORG}/code-security/configurations" \
-f name="hth-hardened" \
-f description="How To Harden security baseline configuration" \
-f dependency_graph="enabled" \
-f dependabot_alerts="enabled" \
-f dependabot_security_updates="enabled" \
-f secret_scanning="enabled" \
-f secret_scanning_push_protection="enabled" \
-f code_scanning_default_setup="enabled" \
-f private_vulnerability_reporting="enabled"
# Attach a security configuration to all repositories in the organization
CONFIG_ID="${1:?Usage: $0 <config_id>}"
gh api --method POST \
"/orgs/${GITHUB_ORG}/code-security/configurations/${CONFIG_ID}/attach" \
-f scope="all"
Code Pack: CLI Script
name: Build and Attest
on:
push:
branches: [main]
release:
types: [published]
permissions:
id-token: write
contents: read
attestations: write
jobs:
build-and-attest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: |
# Replace with your build steps
make build
echo "ARTIFACT_PATH=dist/my-app" >> "$GITHUB_ENV"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: ${{ env.ARTIFACT_PATH }}
build-and-attest-container:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
attestations: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Generate container attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'code_security_configuration.detach'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: API Script
# Verify a binary artifact attestation using the GitHub CLI
# Usage: Replace PATH/TO/ARTIFACT with your build artifact
gh attestation verify PATH/TO/ARTIFACT \
-R "${GITHUB_ORG}/${REPO}"
# Verify a container image attestation
gh attestation verify oci://ghcr.io/${GITHUB_ORG}/${REPO}:latest \
-R "${GITHUB_ORG}/${REPO}"
# Verify with specific SBOM predicate type (SPDX)
gh attestation verify PATH/TO/ARTIFACT \
-R "${GITHUB_ORG}/${REPO}" \
--predicate-type https://spdx.dev/Document/v2.3
Validation & Testing
- Run the attestation workflow and confirm it succeeds
- Verify the attestation using
gh attestation verify - Confirm attestation metadata includes correct repository and workflow references
- For container images, verify attestation is pushed to the registry
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC8.1 | Change management - artifact integrity |
| NIST 800-53 | SI-7, SA-12 | Integrity, supply chain protection |
| ISO 27001 | A.14.2.7 | Outsourced development integrity |
| SLSA | Build L2/L3 | Build provenance and isolation |
3.6 Harden Actions OIDC Subject Claims
Profile Level: L2 (Hardened) NIST 800-53: IA-2, IA-8 CIS Controls: 6.3
Description
Customize GitHub Actions OIDC subject claims to include repository, environment, and job_workflow_ref for fine-grained cloud provider trust policies. By default, the OIDC subject claim only includes the repository and ref, which may allow unintended workflows to assume cloud roles. Customizing claims prevents OIDC token spoofing across repositories and workflows. The check_run_id claim (added November 2025) further improves auditability.
Rationale
Attack Prevented: OIDC token spoofing, cross-repository credential theft
Why This Matters:
- Default OIDC subject claims use
repo:ORG/REPO:ref:refs/heads/BRANCHformat - Any workflow in that repo/branch can assume the cloud role – not just your deploy workflow
- Customizing claims to include
job_workflow_refrestricts access to specific reusable workflows - Environment-based claims restrict access to workflows targeting specific deployment environments
- Colons in environment names are now URL-encoded to prevent claim injection attacks
ClickOps Implementation
Step 1: Configure at Repository Level
- Navigate to: Repository Settings -> Environments -> Select environment
- Under “OpenID Connect”:
- Click “Use custom template” (if available via API)
- Alternatively, use the API to set custom claims (see Code Pack below)
Step 2: Configure at Organization Level
- Use the REST API to set organization-wide OIDC claim defaults
- Include
repo,context, andjob_workflow_refin the subject claim - See the Code Pack below for API implementation
Step 3: Update Cloud Provider Trust Policies
- Update AWS IAM, GCP Workload Identity, or Azure trust policies to match new claim format
- Test with a non-production environment first
- Gradually roll out to production
Time to Complete: ~20 minutes + cloud provider policy updates
Code Implementation
Code Pack: API Script
# Customize OIDC subject claims to include repository, environment, and job_workflow_ref
# This restricts which workflows can assume cloud roles
info "3.21 Customizing OIDC subject claims for ${GITHUB_ORG}/${REPO}..."
RESPONSE=$(gh_put "/repos/${GITHUB_ORG}/${REPO}/actions/oidc/customization/sub" '{
"use_default": false,
"include_claim_keys": [
"repo",
"context",
"job_workflow_ref"
]
}') || {
fail "3.21 Failed to customize OIDC claims"
increment_failed
summary
exit 0
}
# Verify the customization
info "3.21 Current OIDC subject claim template:"
gh_get "/repos/${GITHUB_ORG}/${REPO}/actions/oidc/customization/sub" \
| jq '.'
# Set organization-level OIDC defaults
info "3.21 Setting organization OIDC subject claim template..."
gh_put "/orgs/${GITHUB_ORG}/actions/oidc/customization/sub" '{
"include_claim_keys": [
"repo",
"context",
"job_workflow_ref"
]
}'
Validation & Testing
- Verify OIDC claims are customized using the API
- Test that only the intended workflow can assume the cloud role
- Verify that a different workflow in the same repo is denied access
- Confirm
check_run_idappears in OIDC tokens for audit purposes
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access security |
| NIST 800-53 | IA-2, IA-8 | Identification and authentication |
| ISO 27001 | A.9.4.2 | Secure log-on procedures |
| CIS Controls | 6.3 | Require MFA for externally-exposed applications |
3.7 Recommended Open Source Security Tools
Profile Level: L1 (Baseline) NIST 800-53: SA-11, CM-6 CIS Controls: 16.4
Description
Deploy a layered set of open source tools to continuously harden GitHub Actions workflows. These tools address different phases of the security lifecycle: static analysis before merge, one-time configuration hardening, continuous runtime monitoring, and periodic organization-wide governance audits.
Rationale
Attack Prevented: Workflow injection, action supply chain compromise, excessive permissions, runtime tampering
Real-World Incidents:
- tj-actions/changed-files (March 2025, CVE-2025-30066): A compromised GitHub Action affected 23,000+ repositories by rewriting a mutable tag to point at malicious code. This incident validated the layered approach: SHA pinning (Layer 2) would have prevented the tag-rewriting vector, and harden-runner (Layer 3) detected the attack at runtime via anomalous network egress.
- trivy-action / TeamPCP (March 2026): 75 release tags poisoned with a three-stage credential stealer that read
/proc/*/memand exfiltrated cloud credentials to a typosquat domain. Harden-Runner detected the anomalous C2 callout toscan.aquasecurtiy.orgwithin hours. zizmor would have flagged the pre-existingpull_request_targetvulnerability (CVE-2026-26189) that enabled the initial PAT theft.
Why This Matters: No single tool covers all attack surfaces. Static linters catch injection patterns before merge, pinning tools eliminate mutable tag risks, runtime agents detect zero-day compromises, and governance scanners enforce organization-wide policy compliance.
Tool Inventory
Recommended implementation order: Layer 1 (static analysis) first for immediate visibility, then Layer 2 (hardening) for quick wins, then Layer 3 (monitoring) for ongoing protection, and finally Layer 4 (governance) for periodic audits.
| Tool | Category | Stars | License | Integration |
|---|---|---|---|---|
| zizmor | Static Analysis | 3,700+ | MIT | CLI, Action, SARIF |
| actionlint | Static Analysis | 3,600+ | MIT | CLI, Action, Docker |
| Gato-X | Static Analysis | 480+ | Apache-2.0 | CLI |
| secure-repo | Config Hardening | 300+ | AGPL-3.0 | Web, CLI |
| pin-github-action | Config Hardening | 140+ | MIT | CLI (npx) |
| actions-permissions | Config Hardening | 350+ | MIT | Action |
| clank | Static Analysis | 200+ | Apache-2.0 | CLI |
| harden-runner | Runtime Monitoring | 980+ | Apache-2.0 | Action |
| Allstar | Continuous Policy | 1,390+ | Apache-2.0 | GitHub App |
| OpenSSF Scorecard | Continuous Policy | 5,290+ | Apache-2.0 | Action, CLI |
| Legitify | Org Governance | 830+ | Apache-2.0 | CLI, Action |
Layer 1: Static Analysis (Pre-Merge)
Run these tools in CI to catch workflow security issues before they reach the default branch.
- zizmor – Security linter with 24+ audit rules covering injection, credential exposure, and Actions anti-patterns. Produces SARIF output for GitHub Code Scanning integration.
- actionlint – Type-checker for workflow files that detects template injection vulnerabilities, invalid glob patterns, and runner label mismatches.
- Gato-X – Offensive security tool that enumerates exploitable Actions misconfigurations including self-hosted runner attacks, pull_request_target abuse, and GITHUB_TOKEN over-permissions.
- clank – Chainguard’s imposter commit detector (
chainguard-dev/clank). Scans workflow files to verify that pinned action SHAs are reachable from the parent repository’s branches, not just resolvable via GitHub’s fork network. Catches SHAs that originate from forks and could bypass allow-list policies. See Section 3.10.
Layer 2: Configuration Hardening (One-Time)
Apply these tools once (then periodically re-run) to harden workflow configurations.
- secure-repo – Automatically pins actions to commit SHAs, sets minimal permissions, and adds harden-runner steps. Provides a web UI at app.stepsecurity.io for one-click PRs.
- pin-github-action – Converts mutable action version tags (e.g.,
@v4) to pinned commit SHAs. Run vianpx pin-github-action .github/workflows/*.yml. - actions-permissions – Monitors actual GITHUB_TOKEN usage across workflows and recommends minimum required permission scopes.
Layer 3: Continuous Monitoring (Always-On)
Deploy these tools for ongoing runtime protection and posture scoring.
- harden-runner – EDR-like runtime agent that monitors network egress, file system access, and process execution within Actions runners. Detected the tj-actions compromise via anomalous outbound connections.
- Allstar – OpenSSF project that continuously enforces security policies (branch protection, security file presence, binary artifacts) across all organization repositories via a GitHub App.
- OpenSSF Scorecard – Scores repository security posture across 18 checks on a 0-10 scale. Run as a GitHub Action on a schedule to track security improvements over time.
Layer 4: Organization Governance (Periodic)
Run these tools periodically to audit organization-wide security posture.
- Legitify – Scans GitHub (and GitLab) organizations for misconfigurations including unprotected branches, missing MFA enforcement, stale PATs, and overly permissive webhook configurations.
ClickOps Implementation
Step 1: Add Static Analysis to CI
- Create a workflow file
.github/workflows/actions-security.yml - Add zizmor and actionlint as steps (see Code Pack below)
- Configure SARIF upload for GitHub Code Scanning integration
Step 2: Run Configuration Hardening
- Visit app.stepsecurity.io and connect your repository
- Review the generated PR that pins actions and adds harden-runner
- Alternatively, run
npx pin-github-actionlocally on your workflow files
Step 3: Enable Runtime Monitoring
- Add the
step-security/harden-runner@v2step as the first step in each job - Start in
audit-modeto observe before switching toblockmode
Step 4: Deploy Organization Governance
- Install the Allstar GitHub App from the GitHub Marketplace
- Configure policies in an
.allstarrepository - Schedule monthly Legitify scans via CI or run manually
Time to Complete: ~30 minutes for initial setup
Validation & Testing
- zizmor and actionlint run on PRs and report findings
- All actions in workflows are pinned to commit SHAs
- harden-runner is present as the first step in critical workflows
- OpenSSF Scorecard produces a score for the repository
- Legitify scan shows no critical findings at the org level
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC7.1, CC8.1 | Detection and monitoring, change management |
| NIST 800-53 | SA-11, CM-6 | Developer security testing, configuration management |
| ISO 27001 | A.14.2.8 | System security testing |
| CIS Controls | 16.4 | Establish and manage an inventory of third-party software |
3.8 Secure pull_request_target Workflows Against Pwn Requests
Profile Level: L1 (Baseline) NIST 800-53: AC-3, SI-7 CIS Controls: 16.1
Description
Prevent pull_request_target workflows from executing untrusted PR code with elevated privileges. The pull_request_target event runs in the context of the base repository with access to secrets and write permissions — if the workflow checks out and executes code from the PR head, an attacker controls what runs with those privileges.
Rationale
Attack Vector: Pwn Request — a forked PR triggers a pull_request_target workflow that checks out the attacker’s code, which then runs with the base repository’s secrets and permissions.
Real-World Incidents:
- hackerbot-claw / Trivy (February 2026): An AI-powered bot exploited
pull_request_targetworkflows across 7 Aqua Security repositories includingaquasecurity/trivy. The bot opened PRs that triggered workflows checking out untrusted code, enabling theft of a Personal Access Token (PAT) that was later used to privatize the trivy repository and poison 75 release tags. - SpotBugs → reviewdog → tj-actions chain (2024-2025): The root cause of the tj-actions/changed-files compromise (CVE-2025-30066, 23,000+ repos affected) was a
pull_request_targetvulnerability in SpotBugs, which gave attackers a PAT used to compromise reviewdog, then tj-actions.
Why This Matters: pull_request_target is the single most exploited GitHub Actions attack surface. Unlike pull_request, it gives forked PR code access to repository secrets. A single unsafe workflow can compromise the entire repository and its downstream supply chain.
ClickOps Implementation
Step 1: Audit Existing Workflows
- Search your
.github/workflows/directory forpull_request_target - For each workflow found, verify it does NOT checkout PR head code:
- Check for
actions/checkoutwithref: ${{ github.event.pull_request.head.sha }} - Check for
actions/checkoutwithref: ${{ github.event.pull_request.head.ref }} - Either of these patterns is UNSAFE when combined with
pull_request_target
- Check for
- Verify no
run:blocks interpolate${{ github.event.pull_request.title }},${{ github.event.pull_request.body }}, or other attacker-controlled fields directly (expression injection)
Step 2: Apply Safe Patterns
- Split-workflow pattern (recommended): Run untrusted builds in
pull_request(no secrets), perform trusted operations (labeling, commenting, deploying) in a separatepull_request_targetworkflow that never checks out PR code - Artifact handoff pattern: Build artifacts in
pull_request, upload them, then download and consume inpull_request_target - Metadata-only pattern: If
pull_request_targetonly needs PR metadata (title, labels, author), useactions/github-scriptto read the API instead of checking out code
Step 3: Prevent Expression Injection
- Never use
${{ github.event.pull_request.* }}directly inrun:blocks - Pass untrusted values through environment variables instead
- Use
actions/github-scriptfor operations that need PR content
Time to Complete: ~30 minutes to audit + refactor workflows
Code Implementation
Code Pack: CLI Script
# PATTERN 1: SAFE — Untrusted build in pull_request (no secrets)
# File: .github/workflows/pr-build.yml
name: PR Build (Untrusted)
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm ci
- run: npm test
- run: npm run build
---
# PATTERN 2: SAFE — Trusted labeling via pull_request_target
# Runs in BASE repo context with secrets, but NEVER checks out PR code.
# File: .github/workflows/pr-label.yml
name: PR Label (Trusted — Metadata Only)
on:
pull_request_target:
types: [opened, synchronize]
permissions:
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const files = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
const hasDocs = files.data.some(f => f.filename.startsWith('docs/'));
if (hasDocs) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['documentation']
});
}
---
# PATTERN 3: SAFE — Expression injection prevention
# Pass untrusted PR fields through env vars, not ${{ }} in run blocks
# File: .github/workflows/pr-comment.yml
name: PR Comment (Safe Input Handling)
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
welcome:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
with:
script: |
const title = process.env.PR_TITLE;
const author = process.env.PR_AUTHOR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Thanks @${author} for "${title}". A maintainer will review shortly.`
});
Validation & Testing
- No
pull_request_targetworkflow checks out PR head code - No
run:blocks directly interpolate${{ github.event.pull_request.* }} - zizmor audit passes with no
pull_request_targetfindings - Fork the repo, submit a test PR, verify the workflow does not expose secrets
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access security |
| NIST 800-53 | AC-3, SI-7 | Access enforcement, software integrity |
| SLSA | Build L2 | Scripted build, version controlled |
| CIS Controls | 16.1 | Establish secure coding practices |
3.9 Enforce Runner Process and Network Isolation
Profile Level: L2 (Hardened) NIST 800-53: SC-7, SC-39 CIS Controls: 13.4
Description
Harden GitHub Actions runners against credential theft from process memory and unauthorized network exfiltration. This control addresses attacks where malicious action code reads secrets from the runner’s process memory (/proc/*/mem) and exfiltrates them to attacker-controlled infrastructure.
Rationale
Attack Vector: Process memory harvesting and C2 exfiltration — malicious action code reads /proc/*/mem to extract secrets from the Runner.Worker process, then exfiltrates credentials over HTTPS to typosquat domains.
Real-World Incident:
- TeamPCP Cloud Stealer / trivy-action (March 2026): After poisoning 75 trivy-action tags, attackers deployed a three-stage payload: (1) a base64-encoded Python script decoded and executed at runtime, (2) read
/proc/*/memfrom theRunner.Workerprocess to harvest cloud credentials, SSH keys, and tokens, (3) exfiltrated stolen secrets toscan.aquasecurtiy.org(typosquat ofaquasecurity.org). The payload also attempted to create a repo namedtpcp-docsin the victim’s account as a fallback exfiltration channel.
Why This Matters: GitHub-hosted runners do not restrict /proc access or network egress by default. Any action running in the workflow can read the process memory of the runner itself — where decrypted secrets are held during execution. Without egress controls, exfiltrated data leaves the runner undetected.
ClickOps Implementation
Step 1: Deploy Harden-Runner with Egress Enforcement
- Add
step-security/harden-runneras the first step in every workflow job - Start with
egress-policy: auditto observe legitimate network connections - After 1-2 weeks, review the StepSecurity dashboard for the allow-list
- Switch to
egress-policy: blockwith an explicitallowed-endpointslist - Only allow endpoints your workflow actually needs (GitHub APIs, package registries)
Step 2: Harden Self-Hosted Runner Containers (if applicable)
- Run runner containers as non-root with
runAsUser: 1000 - Set
readOnlyRootFilesystem: truewith writable tmpfs for_workand/tmp - Drop ALL Linux capabilities:
capabilities: { drop: ["ALL"] } - Apply a seccomp profile that blocks
ptrace,process_vm_readv, andprocess_vm_writevsyscalls - Set
allowPrivilegeEscalation: false
Step 3: Enable Harden-Runner Advanced Policies
- Compromised Actions Policy: Automatically cancels workflow jobs before compromised action code can execute. Harden-Runner maintains a database of known-compromised action SHAs and blocks them at job start.
- Lockdown Mode: Blocks job execution when suspicious process events are detected, such as a process reading
Runner.Workermemory via/proc/<pid>/mem. In the Trivy v0.69.4 attack, Harden-Runner detectedpython3(PID 2538) reading/proc/2167/memto extract secrets. - Secret Exfiltration Policy: Automatically cancels job runs when a newly added or modified workflow step accesses secrets — catches cases where a compromised action introduces secret-harvesting behavior not present in previous versions.
Step 4: Apply Network Policies (self-hosted runners)
- Create a Kubernetes NetworkPolicy restricting runner pod egress
- Allow only DNS (port 53) and HTTPS (port 443) to known endpoints
- Block all other outbound traffic including SSH, HTTP, and non-standard ports
- Monitor blocked connections for anomaly detection
Time to Complete: ~45 minutes (Harden-Runner); ~2 hours (K8s runner hardening)
Code Implementation
Code Pack: CLI Script
# PATTERN 1: Harden-Runner with egress enforcement
# Add as FIRST step in every workflow job
# File: .github/workflows/ci.yml
name: CI with Egress Enforcement
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
pypi.org:443
files.pythonhosted.org:443
ghcr.io:443
docker.io:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm ci
- run: npm test
---
# PATTERN 2: Kubernetes ephemeral runner with security context
# Blocks /proc/*/mem access and restricts syscalls
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: hardened-ephemeral-runners
namespace: github-runners
spec:
template:
spec:
ephemeral: true
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: Localhost
localhostProfile: profiles/runner-restricted.json
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: tmp
mountPath: /tmp
volumes:
- name: work
emptyDir: {}
- name: tmp
emptyDir:
sizeLimit: 1Gi
---
# PATTERN 3: Seccomp profile blocking /proc memory access
# File: /var/lib/kubelet/seccomp/profiles/runner-restricted.json
# Prevents TeamPCP attack vector (reading /proc/*/mem)
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["ptrace", "process_vm_readv", "process_vm_writev"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1
}
]
}
Validation & Testing
- Harden-Runner is the first step in all critical workflow jobs
- Egress policy is set to
block(notaudit) in production workflows - Allowed endpoints list contains only necessary domains
- Self-hosted runner containers run as non-root with dropped capabilities
- Seccomp profile blocks
ptraceand/procmemory access syscalls - Test: a workflow step attempting
curl https://evil.example.comis blocked
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1, CC6.6 | Logical access, system boundaries |
| NIST 800-53 | SC-7, SC-39 | Boundary protection, process isolation |
| ISO 27001 | A.13.1.1 | Network controls |
| CIS Controls | 13.4 | Perform traffic filtering between network segments |
3.10 Detect and Prevent Action Tag Poisoning
Profile Level: L1 (Baseline) NIST 800-53: SI-7, SA-12 CIS Controls: 2.5
Description
Detect and prevent attacks where an adversary force-pushes Git tags in an action repository to point at malicious commits. Tag poisoning silently replaces trusted action code with attacker-controlled payloads for every consumer using mutable tag references (e.g., @v1, @v2).
Rationale
Attack Vector: Git tags are mutable pointers. An attacker with push access to a repository (via stolen PAT, compromised maintainer, or pull_request_target exploit) can force-push any tag to point at a different commit containing malicious code.
Real-World Incidents:
- trivy-action (March 2026): Attackers poisoned 75 of 76 release tags (all except the latest
v0.62.1) to point at malicious commits containing the TeamPCP Cloud Stealer. The poisoned commits were “imposter commits” — reachable via tags but not on any branch, with fabricated commit dates to appear legitimate. None of the poisoned tags had GPG signatures, unlike the legitimate originals. - tj-actions/changed-files (March 2025, CVE-2025-30066): All mutable tags were rewritten to point at a malicious commit that exfiltrated CI secrets to a GitHub Gist. The attack affected 23,000+ repositories before detection.
Fork Network Bypass (Imposter Commits): GitHub shares Git objects between forks and parent repositories via “alternates.” This means a commit pushed to a fork of an allowed action can be referenced using the parent repository’s path (e.g., actions/checkout@<fork-commit-sha>), and GitHub resolves it as if it belongs to the parent. This bypasses organization-level Actions allow-list policies that restrict to “GitHub-verified creators only.” Even SHA-pinned references are vulnerable if the SHA originates from a fork rather than the parent’s branch history.
Cross-Channel Propagation: The Trivy attack demonstrated that poisoning a single source artifact cascades across multiple distribution channels simultaneously. After the trivy binary was poisoned, it auto-propagated to Docker Hub (aquasec/trivy:0.69.5, 0.69.6 pushed with no GitHub release — 0.69.6 tagged as latest), Homebrew (auto-pulled v0.69.4 before emergency downgrade), and Helm charts (automated bump PR). Container images referenced in workflows via mutable tags (e.g., container: aquasec/trivy:latest) are equally vulnerable to tag manipulation — pin Docker images by digest in workflow files, not just Actions by SHA. See the Docker Hub Hardening Guide for container-specific controls.
Why This Matters: Most workflow files reference actions by mutable tag (@v4). When a tag is poisoned, every workflow run automatically picks up the malicious code — no PR, no review, no notification. SHA pinning is the primary defense, but the pinned SHA must be verified as reachable from a known branch or tag in the parent repository — not just resolvable via the GitHub API.
ClickOps Implementation
Step 1: Pin All Actions to Full Commit SHAs
- Run
frizbee ghactions .github/workflows/*.ymlornpx pin-github-action .github/workflows/*.ymlto convert tag references to SHA pins - Add version comments after each SHA for readability:
@abc123 # v4.1.1 - Pin container images in
container:andservices:directives by digest (e.g.,node:18@sha256:a1b2c3...) — usefrizbee containers .github/workflows/*.ymlto automate this - Configure Dependabot or Renovate to automatically propose SHA and digest updates when new versions release
- Enable GitHub’s organization-level SHA pinning policy (Settings > Actions > Policies > “Require actions to use full-length commit SHAs”) to block new unpinned references
Step 2: Audit Composite Action and Reusable Workflow Transitive Dependencies
- SHA-pinning an outer action does NOT pin its internal dependencies — a composite action pinned by SHA may internally reference
actions/checkout@v4(a mutable tag), which is resolved at runtime and can be poisoned independently - Use
poutine(BoostSecurity) to detect unpinned transitive dependencies inside composite actions:docker run ghcr.io/boostsecurityio/poutine:latest analyze_repo --token $GITHUB_TOKEN org/repo - Use
octopinto list all transitive action dependencies including those inside composite actions:octopin list --transitive .github/workflows/ - For actions with unpinned internal dependencies: fork and pin internally, file an upstream PR, or use
gh-actions-lockfileto generate and verify a lockfile with SHA-256 integrity hashes for the full transitive tree - Reusable workflows have the same problem — if a reusable workflow you call internally uses unpinned actions, those are vulnerable even if you pin the workflow itself by SHA
- Docker-based actions that reference
docker://image:tagin theiraction.ymlare “unpinnable” — the container image is resolved at runtime regardless of the action’s SHA pin. Research by Palo Alto found 32% of top 1,000 Marketplace actions are unpinnable for this reason. Mitigations: fork and pin the Docker image digest, or use runtime monitoring (Harden-Runner) to detect anomalous behavior
Step 3: Enable Dependabot for GitHub Actions
- Create or update
.github/dependabot.ymlin your repository - Add both
github-actionsanddockerecosystem entries with weekly update schedule - Dependabot will propose PRs when pinned SHAs and image digests become outdated
Step 5: Monitor for Tag Poisoning and Imposter Commits
- Imposter commits: Tags pointing to commits not reachable from any branch — use Chainguard’s
clanktool (chainguard-dev/clank) to automatically detect imposter commits in workflow files - Missing signatures: Tags that previously had GPG signatures suddenly lack them
- Timestamp anomalies: Tag commits with dates significantly different from surrounding commits
- Unexpected tag updates: GitHub audit log entries for
git.pushevents on tag refs - Fork-origin SHAs: Verify pinned SHAs are reachable from the parent repo’s default branch, not just resolvable via the API (the API returns fork commits without warning)
- Run the SHA verification audit script (see Code Pack) on a schedule
Step 6: Sign Action Release Tags
- Use Sigstore Gitsign for keyless, transparent signing of Git tags and commits in action repositories you maintain (see Section 2.4 for Gitsign setup)
- Per-repository signing identities ensure consumers can verify tag authenticity
- Treat published actions as release artifacts — sign them with the same rigor as container images or packages
Step 7: Deploy Runtime Detection
- Add StepSecurity Harden-Runner to detect anomalous network egress from action steps
- Harden-Runner detected the Trivy compromise within hours via unexpected outbound connections to
scan.aquasecurtiy.org
Time to Complete: ~20 minutes (SHA pinning); ~10 minutes (Dependabot config)
Code Implementation
Code Pack: CLI Script
# Audit 1: Find action references NOT pinned to full SHAs
echo "=== Unpinned Action References ==="
find .github/workflows -name '*.yml' -o -name '*.yaml' | while read -r file; do
grep -nE 'uses:\s+[^#]+@' "$file" | \
grep -vE '@[0-9a-f]{40}' | \
while read -r line; do
echo " $file:$line"
done
done
echo "Fix: npx pin-github-action .github/workflows/*.yml"
# Audit 2: Verify pinned SHAs match expected tagged versions
echo ""
echo "=== SHA Verification ==="
find .github/workflows -name '*.yml' -o -name '*.yaml' | while read -r file; do
grep -oE 'uses:\s+([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)@([0-9a-f]{40})\s+#\s*(v[0-9]+[^\s]*)' "$file" | \
sed 's/uses:\s*//' | while read -r match; do
action=$(echo "$match" | cut -d'@' -f1)
sha=$(echo "$match" | cut -d'@' -f2 | cut -d' ' -f1)
expected_tag=$(echo "$match" | grep -oE 'v[0-9]+[^\s]*')
if [ -n "$expected_tag" ]; then
actual_sha=$(gh api "repos/$action/git/ref/tags/$expected_tag" \
--jq '.object.sha' 2>/dev/null || echo "FAILED")
if [ "$actual_sha" = "$sha" ]; then
echo " OK: $action@$expected_tag"
elif [ "$actual_sha" = "FAILED" ]; then
echo " WARN: $action@$expected_tag (API error)"
else
echo " ALERT: $action@$expected_tag SHA MISMATCH!"
echo " Pinned: $sha"
echo " Current: $actual_sha"
echo " Possible tag poisoning!"
fi
fi
done
done
# Dependabot config for automatic SHA updates
# File: .github/dependabot.yml
cat <<'DEPENDABOT'
# Recommended: .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
DEPENDABOT
Validation & Testing
- All action references use full 40-character commit SHAs
- All
container:andservices:images pinned by digest - Composite action transitive dependencies audited with
poutineoroctopin - Dependabot or Renovate is configured for
github-actionsanddockerecosystems - SHA verification audit runs on a schedule (weekly minimum)
- No SHA mismatches detected between pinned values and tag targets
clankscan confirms no imposter commits (SHAs reachable from parent branches only)- Harden-Runner is deployed for runtime anomaly detection
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC7.1, CC8.1 | Detection and monitoring, change management |
| NIST 800-53 | SI-7, SA-12 | Software integrity verification, supply chain protection |
| SLSA | Build L2 | Pinned dependencies |
| CIS Controls | 2.5 | Allowlist authorized software |
4. OAuth & Third-Party App Security
4.1 Audit and Restrict OAuth App Access
Profile Level: L1 (Baseline) NIST 800-53: AC-6, AC-17 CIS Controls: 6.8
Description
Review all OAuth apps and GitHub Apps with access to your organization. Revoke unnecessary apps, restrict scopes for remaining apps, and enable OAuth app access restrictions to require admin approval for new installations.
Rationale
Attack Vector: OAuth tokens remain valid until manually revoked. Most organizations have no visibility into which apps retain access or what scopes they hold.
Real-World Incidents:
- CircleCI Breach (January 2023): Tokens for GitHub, AWS, and other services stolen. GitHub OAuth tokens allowed repository access across customer organizations.
- Heroku/Travis CI (April 2022): GitHub OAuth tokens leaked, used for unauthorized repository access affecting npm packages.
- GitHub SSO Bypass (CVE-2024-6337, July 2024): GitHub App installation tokens could read private repository contents even when SAML SSO hadn’t authorized the app, allowing unauthorized data access.
ClickOps Implementation
Step 1: Enable OAuth App Access Restrictions
- Organization Settings -> Third-party access -> OAuth application policy
- Click “Setup application access restrictions”
- Review pending requests and approve only necessary apps
- This ensures unapproved OAuth apps cannot access organization data
Step 2: Audit Installed Apps
- Organization Settings -> GitHub Apps (for GitHub Apps)
- Organization Settings -> OAuth Apps (for OAuth apps)
- For each app, review:
- Last used date
- Granted permissions and scopes
- Repository access (all repos vs. selected)
- Click app -> “Configure” -> Review repository access and permissions
Step 3: Revoke Unnecessary Apps
- Click “Revoke” for unused OAuth apps
- For GitHub Apps, click “Suspend” or “Uninstall”
- For remaining apps, restrict repository access to minimum necessary
Step 4: Limit Access Requests
- Organization Settings -> Third-party access -> Access requests
- Configure whether outside collaborators can request app access
- Set notification preferences for pending requests
Time to Complete: ~30 minutes for initial audit
Code Implementation
Code Pack: API Script
# List organization authorized OAuth apps
info "4.04 Listing authorized OAuth apps for ${GITHUB_ORG}..."
APPS=$(gh_get "/orgs/${GITHUB_ORG}/installations") || {
warn "4.04 Unable to retrieve app list (may require admin scope)"
}
echo "${APPS}" | jq '.installations[] | {app: .app_slug, permissions: .permissions, created_at: .created_at}'
# NOTE: The /applications endpoint was deprecated by GitHub in 2019.
# Use the Admin Console (Settings > OAuth Apps) to review personal OAuth authorizations.
Code Pack: Sigma Detection Rule
detection:
selection_install:
action:
- 'integration_installation.create'
- 'integration_installation.destroy'
selection_oauth:
action:
- 'oauth_authorization.create'
- 'oauth_authorization.destroy'
condition: selection_install or selection_oauth
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: SDK Script
from github import Github
import os
g = Github(os.environ['GITHUB_TOKEN'])
org = g.get_organization('your-org')
print("Authorized OAuth Apps:")
print("=" * 60)
# Note: GitHub API doesn't provide full OAuth app list
# This must be done via UI or GraphQL API
# Placeholder for manual review tracking
apps = [
{"name": "CircleCI", "last_used": "2025-12-01", "keep": True},
{"name": "Old-CI-Tool", "last_used": "2023-06-15", "keep": False},
]
for app in apps:
status = "Keep" if app["keep"] else "Revoke"
print(f"{status}: {app['name']} (last used: {app['last_used']})")
Code Pack: API Script
# Audit: Check for deploy keys with write access (read_only == false)
KEYS=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/keys") || {
fail "4.03 Unable to retrieve deploy keys for ${GITHUB_ORG}/${REPO}"
increment_failed
summary
exit 0
}
TOTAL_KEYS=$(echo "${KEYS}" | jq '. | length' 2>/dev/null || echo "0")
WRITE_KEYS=$(echo "${KEYS}" | jq '[.[] | select(.read_only == false)] | length' 2>/dev/null || echo "0")
READ_KEYS=$((TOTAL_KEYS - WRITE_KEYS))
info "4.03 Found ${TOTAL_KEYS} deploy key(s): ${READ_KEYS} read-only, ${WRITE_KEYS} read-write"
# List write-access keys for review
if [ "${WRITE_KEYS}" -gt 0 ]; then
warn "4.03 Deploy keys with WRITE access (review required):"
echo "${KEYS}" | jq -r '.[] | select(.read_only == false) | " ID: \(.id) | Title: \(.title) | Created: \(.created_at)"' 2>/dev/null
fi
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'deploy_key.create'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Recommended Scope Restrictions
| App Type | Recommended Scopes | Avoid |
|---|---|---|
| CI/CD (CircleCI, Jenkins) | repo (read), status (write), write:packages (if needed) |
admin:org, delete_repo |
| Code Analysis (SonarQube) | repo:read, statuses:write |
repo:write |
| Project Management (Jira) | repo:status, read:org |
repo (full) |
| Dependency Tools (Snyk) | repo:read, security_events:write |
repo:write |
Compliance Mappings
- CIS Controls: 6.8 (Define and maintain role-based access control)
- NIST 800-53: AC-6 (Least Privilege), AC-17 (Remote Access)
- SOC 2: CC6.2 (Least privilege), CC9.2 (Third-party access)
- ISO 27001: A.9.2.1
4.2 Audit GitHub App Installation Permissions
Profile Level: L1 (Baseline) NIST 800-53: AC-6, CM-8 CIS Controls: 2.1
Description
Audit all installed GitHub Apps in the organization, review their granted permissions, and flag apps with excessive access. GitHub Apps have replaced OAuth apps as the preferred integration method due to finer-grained permission controls.
Rationale
Why GitHub Apps Over OAuth Apps:
- GitHub Apps request only specific permissions (not broad scopes)
- Can be installed on specific repositories (not all-or-nothing)
- Use short-lived installation tokens (expire after 1 hour)
- Each permission can be set to read-only, read-write, or no access
Risk: Apps with administration: write or organization_administration: write permissions can modify org settings, manage teams, and change repository visibility.
ClickOps Implementation
Step 1: Review Installed GitHub Apps
- Navigate to: Organization Settings -> GitHub Apps
- For each installed app, click “Configure”
- Review:
- Repository access: All repositories vs. specific repositories
- Permissions: Which permissions were granted at install time
- Events: What webhook events the app receives
Step 2: Restrict Repository Access
- For each app, change from “All repositories” to “Only select repositories”
- Select only the repositories the app actually needs
- Click “Save”
Step 3: Flag Excessive Permissions
- Apps should not have
administration: writeunless they manage repo settings - Apps should not have
organization_administration: writeunless they manage org-level config - Review
members: write– apps generally should not manage team membership
Time to Complete: ~20 minutes
Code Implementation
Code Pack: API Script
# List existing artifact attestations for a specific artifact digest
# Replace DIGEST with the actual artifact digest (sha256:...)
DIGEST="${1:-sha256:example}"
gh api "/orgs/${GITHUB_ORG}/attestations/${DIGEST}" \
--jq '.attestations[] | {bundle_media_type: .bundle.mediaType, verified: .bundle.verificationMaterial}'
# Verify artifact attestation using the GitHub CLI
# Replace IMAGE with the container image or artifact path
IMAGE="${1:?Usage: $0 <image_or_artifact>}"
gh attestation verify "${IMAGE}" \
--owner "${GITHUB_ORG}"
Code Pack: CLI Script
name: Build and Attest
on:
push:
tags: ['v*']
permissions:
id-token: write
contents: read
attestations: write
packages: write
jobs:
build-and-attest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: |
# Replace with your actual build command
make build
sha256sum dist/artifact > dist/artifact.sha256
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: 'dist/artifact'
- name: Build and push container image
id: push
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- name: Generate container attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'release.publish'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Compliance Mappings
- CIS Controls: 2.1 (Establish and maintain a software inventory)
- NIST 800-53: AC-6 (Least Privilege), CM-8 (Information system component inventory)
- SOC 2: CC6.2
4.3 Enforce Fine-Grained Personal Access Tokens
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud NIST 800-53: IA-4, IA-5 CIS Controls: 6.3
Description
Require fine-grained personal access tokens (PATs) instead of classic PATs. Fine-grained PATs have mandatory expiration dates, scoped repository access, and specific permissions – eliminating the risks of overly permissive classic tokens.
Rationale
Classic PAT Risks:
- No mandatory expiration – tokens can live forever
- Broad scope –
repogrants full access to ALL repositories - No granular permissions – cannot restrict to specific API operations
- No approval workflow – any user can create tokens with any scope
Fine-Grained PAT Benefits:
- Mandatory expiration (max 1 year)
- Repository-specific access (select individual repos)
- Granular permissions per API category
- Organization can require admin approval before tokens take effect
Real-World Incident:
- Code Signing Certificate Theft (January 2023): Attacker used a compromised PAT to access GitHub repositories and steal encrypted code-signing certificates for GitHub Desktop and Atom.
- Fake Dependabot Commits (July 2023): Stolen GitHub PATs used to inject malicious commits disguised as Dependabot contributions across hundreds of repositories.
ClickOps Implementation
Step 1: Set PAT Policy
- Navigate to: Organization Settings -> Personal access tokens
- Under “Fine-grained personal access tokens”:
- Set to “Allow access via fine-grained personal access tokens”
- Enable “Require approval of fine-grained personal access tokens”
- Under “Personal access tokens (classic)”:
- Set to “Restrict access via personal access tokens (classic)”
Step 2: Review Pending Requests
- Organization Settings -> Personal access tokens -> Pending requests
- Review each request: owner, repositories, permissions, expiration
- Approve or deny based on least-privilege principle
Step 3: Audit Active Tokens
- Organization Settings -> Personal access tokens -> Active tokens
- Review all active fine-grained PATs
- Revoke tokens that are no longer needed or have excessive permissions
Time to Complete: ~10 minutes for policy, ongoing for reviews
Code Implementation
Code Pack: API Script
# List active fine-grained PATs in the organization
info "4.08 Listing fine-grained personal access tokens..."
PATS=$(gh_get "/orgs/${GITHUB_ORG}/personal-access-tokens?per_page=100") || {
warn "4.08 Unable to list PATs (requires org admin with fine_grained_pat scope)"
}
echo "${PATS}" | jq '.[] | {
id: .id,
owner: .owner.login,
repository_selection: .repository_selection,
permissions: .permissions,
access_granted_at: .access_granted_at,
token_expired: .token_expired,
token_expires_at: .token_expires_at
}'
# List pending PAT requests
info "4.08 Listing pending PAT access requests..."
REQUESTS=$(gh_get "/orgs/${GITHUB_ORG}/personal-access-token-requests?per_page=100") || {
warn "4.08 Unable to list PAT requests"
}
echo "${REQUESTS}" | jq '.[] | {id: .id, owner: .owner.login, reason: .reason}'
# Restrict PAT access to the organization (require approval)
info "4.08 Setting fine-grained PAT policy to require approval..."
gh_patch "/orgs/${GITHUB_ORG}" '{
"personal_access_token_requests_enabled": true
}' || {
warn "4.08 Unable to set PAT policy (may require enterprise admin)"
}
pass "4.08 Fine-grained PAT approval requirement configured"
Code Pack: CLI Script
# .github/dependabot.yml - Dependabot configuration with grouped security updates
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
production-dependencies:
dependency-type: "production"
update-types:
- "patch"
- "minor"
development-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions-updates:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
groups:
pip-all:
patterns:
- "*"
update-types:
- "patch"
- "minor"
Note: No Terraform provider support exists for fine-grained PAT policies at this time.
Compliance Mappings
- CIS Controls: 6.3 (Require MFA for externally-exposed applications)
- NIST 800-53: IA-4 (Identifier management), IA-5 (Authenticator management)
- SOC 2: CC6.1 (Logical access security)
- ISO 27001: A.9.4.2
5. Secret Management
5.1 Use GitHub Actions Secrets with Environment Protection
Profile Level: L1 (Baseline) NIST 800-53: SC-12, SC-28 CIS Controls: 3.11
Description
Store sensitive credentials in GitHub Actions secrets (not hardcoded in code). Use environment protection rules to require approval for production secret access. Structure secrets at organization, repository, and environment levels for proper access control.
Rationale
Attack Prevention:
- Secrets in code -> exposed in Git history forever
- Secrets in logs -> leaked via CI/CD output
- Secrets in unprotected workflows -> stolen via malicious PR
Real-World Incident:
- tj-actions/changed-files Compromise (March 2025): Supply chain attack on popular GitHub Action (23,000+ repositories). The malicious Action extracted secrets from Runner Worker process memory and dumped them to workflow logs, which were then exfiltrated.
Environment Protection: Require manual approval before workflows can access production secrets.
ClickOps Implementation
Step 1: Configure Organization Secrets
- Navigate to: Organization Settings -> Secrets and variables -> Actions
- Create secrets at organization level for shared credentials
- Restrict repository access to minimum necessary
Step 2: Store Repository Secrets
- Repository Settings -> Secrets and variables -> Actions
- Click “New repository secret”
- Name:
PROD_API_KEY(use descriptive names) - Value: [paste secret]
- Click “Add secret”
Step 3: Create Environment with Protection
- Repository Settings -> Environments
- Click “New environment”, name it
production - Configure protection rules:
- Required reviewers (add team/users who must approve)
- Wait timer (optional: delay before deployment)
- Deployment branches (only
maincan deploy to production)
- Add environment-specific secrets to this environment (most secure)
Step 4: Create Staging Environment
- Create
stagingenvironment with lighter restrictions - Add staging-specific secrets
- Allow deployment from
mainanddevelopbranches
Time to Complete: ~15 minutes
Code Implementation
Code Pack: Terraform
resource "github_repository_environment" "production" {
environment = "production"
repository = var.repository_name
reviewers {
teams = [var.security_team_id]
}
deployment_branch_policy {
protected_branches = true
custom_branch_policies = false
}
}
Code Pack: API Script
# Audit: Check all deployment environments for protection rules
ENVS_DATA=$(gh_get "/repos/${GITHUB_ORG}/${REPO}/environments") || {
warn "5.05 Unable to retrieve environments (may not exist or require admin access)"
increment_applied
summary
exit 0
}
ENVS=$(echo "${ENVS_DATA}" | jq -r '.environments // []')
ENV_COUNT=$(echo "${ENVS}" | jq '. | length' 2>/dev/null || echo "0")
info "5.05 Found ${ENV_COUNT} deployment environment(s)"
if [ "${ENV_COUNT}" = "0" ]; then
pass "5.05 No deployment environments configured (nothing to protect)"
increment_applied
summary
exit 0
fi
UNPROTECTED=0
for ENV_NAME in $(echo "${ENVS}" | jq -r '.[].name' 2>/dev/null); do
RULES_COUNT=$(echo "${ENVS}" | jq "[.[] | select(.name == \"${ENV_NAME}\") | .protection_rules // [] | length] | add // 0" 2>/dev/null || echo "0")
if [ "${RULES_COUNT}" = "0" ]; then
warn "5.05 Environment '${ENV_NAME}' has no protection rules"
UNPROTECTED=$((UNPROTECTED + 1))
else
pass "5.05 Environment '${ENV_NAME}' has ${RULES_COUNT} protection rule(s)"
fi
done
# Add protection rules to unprotected deployment environments
for ENV_NAME in $(echo "${ENVS}" | jq -r '.[].name' 2>/dev/null); do
RULES_COUNT=$(echo "${ENVS}" | jq "[.[] | select(.name == \"${ENV_NAME}\") | .protection_rules // [] | length] | add // 0" 2>/dev/null || echo "0")
if [ "${RULES_COUNT}" = "0" ]; then
info "5.05 Adding protection rules to environment '${ENV_NAME}'..."
gh_put "/repos/${GITHUB_ORG}/${REPO}/environments/${ENV_NAME}" '{
"deployment_branch_policy": {
"protected_branches": true,
"custom_branch_policies": false
}
}' > /dev/null 2>&1 && {
pass "5.05 Protection added to environment '${ENV_NAME}'"
} || {
warn "5.05 Failed to add protection to environment '${ENV_NAME}'"
}
fi
done
Code Pack: CLI Script
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires approval to run
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Deploy
env:
API_KEY: ${{ secrets.PROD_API_KEY }}
run: |
# Secret is available in $API_KEY env var
# NOT visible in logs
deploy.sh
Code Pack: Sigma Detection Rule
detection:
selection_update:
action: 'environment.update_protection_rules'
selection_delete:
action: 'environment.delete'
condition: selection_update or selection_delete
fields:
- actor
- action
- org
- repo
- created_at
Best Practices
Secret Hierarchy (most to least restrictive):
- Environment secrets – Only accessible during deployment to that environment
- Repository secrets – Accessible to all workflows in the repository
- Organization secrets – Shared across selected repositories
Secret Rotation:
- Rotate secrets quarterly (minimum)
- Use short-lived credentials where possible (OIDC tokens – see 5.2)
- Track secret age using the Code Pack below
Never Do:
- Echo secrets in workflow logs:
echo ${{ secrets.API_KEY }} - Write secrets to files that get uploaded as artifacts
- Pass secrets in URLs:
curl https://api.example.com?key=${{ secrets.API_KEY }}
Monitoring
Code Pack: API Script
gh secret list --json name,updatedAt
# Check audit log for secret access
gh api /orgs/{org}/audit-log?phrase=secrets.read
Code Pack: Sigma Detection Rule
detection:
selection:
action:
- 'org_secret.create'
- 'org_secret.update'
- 'org_secret.remove'
- 'repo_secret.create'
- 'repo_secret.update'
- 'repo_secret.remove'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Compliance Mappings
- CIS Controls: 3.11 (Encrypt sensitive data at rest)
- NIST 800-53: SC-12 (Cryptographic key management), SC-28 (Protection of information at rest)
- SOC 2: CC6.1 (Secret management)
- PCI DSS: 8.2.1
5.2 Use OpenID Connect (OIDC) Instead of Long-Lived Credentials
Profile Level: L2 (Hardened) NIST 800-53: IA-5(1) SLSA: Build L3
Description
Use GitHub Actions OIDC provider to get short-lived cloud credentials instead of storing long-lived access keys as secrets.
Rationale
Problem with Long-Lived Secrets:
- Stored in GitHub, accessible to anyone with repo admin access
- If leaked, valid until manually rotated
- Difficult to audit usage
OIDC Advantage:
- No secrets stored in GitHub
- Credentials auto-expire (15 minutes)
- Cloud provider controls access via trust policy
- Can’t be exfiltrated and used elsewhere
Supported Providers:
- AWS (AssumeRoleWithWebIdentity)
- Google Cloud (Workload Identity Federation)
- Azure (Federated Credentials)
- HashiCorp Vault
Container Registries:
- GHCR: Uses
GITHUB_TOKEN(auto-provisioned per workflow run, no static secret) — best zero-config option - AWS ECR / ECR Public: OIDC via AWS IAM →
aws ecr get-login-password - GCP Artifact Registry: OIDC via Workload Identity →
gcloud auth configure-docker - Azure ACR: OIDC via Entra ID →
az acr login - Docker Hub: NO OIDC support. Docker Hub still requires a static username + PAT for pushes. If you use Docker Hub, you cannot eliminate static credentials. Migrate to GHCR, ECR, or Artifact Registry to achieve fully keyless CI/CD. See the Docker Hub Hardening Guide for migration considerations.
Package Registries:
- PyPI: Full OIDC trusted publishing — zero static tokens. Configure trusted publishers at pypi.org linking your GitHub repo/workflow, then use
pypa/gh-action-pypi-publishwithid-token: writepermission. - RubyGems: OIDC trusted publishing supported — configure at rubygems.org.
- npm: OIDC is provenance-only, NOT authentication. npm uses the OIDC token for Sigstore provenance attestation (
--provenanceflag) but still requires a staticNPM_TOKENfor publishing. No OIDC alternative exists for npm auth. - GitHub Packages (npm/Maven/NuGet): Uses
GITHUB_TOKEN— no static secrets needed.
Irreducible Static Secrets: Even with full OIDC adoption, some credentials cannot be eliminated: GitHub App private keys (for cross-repo token minting), npm access tokens, Docker Hub PATs (if you cannot migrate away), and some Dependabot private registry credentials. For cross-repo operations, prefer GitHub Apps over PATs — Apps generate short-lived installation tokens (1-hour expiry) scoped to specific repositories and permissions.
Subject Claim Customization: Customize the OIDC subject claim to include job_workflow_ref for fine-grained cloud provider trust policies. The default sub claim uses repo:ORG/REPO:ref:refs/heads/BRANCH, but any workflow in that repo/branch can assume the cloud role. Adding job_workflow_ref restricts trust to specific deployment workflows. See Section 3.6 for configuration details.
ClickOps Implementation (AWS Example)
Step 1: Configure AWS IAM OIDC Provider
- In AWS IAM Console, create OIDC provider:
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
- Provider URL:
Step 2: Create IAM Role with Trust Policy
- Create IAM role with trust policy for
token.actions.githubusercontent.com - Restrict the
subclaim to your repository and branch - For maximum security, include
job_workflow_refin the condition to restrict to specific deployment workflows
Step 3: Eliminate Docker Hub Static Credentials
- If using Docker Hub for image hosting, migrate to GHCR (re-tag images as
ghcr.io/org/image:tag, update workflow push targets, update Kubernetes/deployment manifests) - GHCR uses
GITHUB_TOKEN— zero static secrets to manage - If Docker Hub migration is not feasible, store the PAT in a GitHub Actions environment with required reviewers to limit exposure
Time to Complete: ~30 minutes per cloud provider; ~2 hours for Docker Hub migration
Code Implementation
Code Pack: API Script
# AWS IAM OIDC Trust Policy
# Create this as the trust policy for your deployment role
cat <<'POLICY'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
POLICY
Code Pack: CLI Script
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
# No long-lived credentials needed!
- name: Deploy
run: |
aws s3 sync ./build s3://my-bucket/
Code Pack: Sigma Detection Rule
detection:
selection:
action:
- 'org.set_actions_oidc_custom_claims_policy'
- 'repo.set_actions_oidc_custom_claims_policy'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Compliance Mappings
- SLSA: Build L3 (Short-lived credentials)
- NIST 800-53: IA-5(1) (Authenticator management)
- SOC 2: CC6.1
5.3 Configure Push Protection with Delegated Bypass
Profile Level: L2 (Hardened) Requires: GitHub Advanced Security (Secret Protection, $19/month per committer as of April 2025) NIST 800-53: IA-5, CM-3
Description
Enable push protection to block commits containing secrets before they reach the repository. Configure delegated bypass so that only designated security reviewers can approve exceptions, rather than allowing any developer to bypass push protection.
Rationale
Why Delegated Bypass:
- Default push protection allows any developer to bypass the block with a reason
- Delegated bypass routes bypass requests to designated reviewers (security team)
- Creates an audit trail of all bypass decisions
- Prevents developers from routinely bypassing push protection without oversight
GHAS Unbundling (April 2025): Secret scanning and push protection are now available as “Secret Protection” ($19/month per committer) separately from Code Security ($30/month). This makes push protection accessible to GitHub Team plan organizations.
ClickOps Implementation
Step 1: Enable Push Protection
- Organization Settings -> Code security -> Configurations
- Edit your security configuration (or create a new one)
- Under Secret scanning, enable Push protection
- Apply to all repositories
Step 2: Configure Delegated Bypass
- Organization Settings -> Code security -> Configurations
- Under push protection settings, set bypass mode to “Require bypass request”
- Designate bypass reviewers (security team or specific users)
- Set notification preferences for bypass requests
Step 3: Monitor Bypass Requests
- Organization Settings -> Code security -> Secret scanning
- Review pending bypass requests
- Approve or deny based on the secret type and context
Time to Complete: ~15 minutes
Code Implementation
Code Pack: Terraform
resource "github_organization_custom_property" "security_tier" {
key = "security-tier"
value_type = "single_select"
required = true
default_value = "standard"
description = "Security classification tier for the repository"
allowed_values = [
"critical",
"high",
"standard",
"low",
]
}
resource "github_organization_custom_property" "data_classification" {
key = "data-classification"
value_type = "single_select"
required = true
default_value = "internal"
description = "Data classification level"
allowed_values = [
"public",
"internal",
"confidential",
"restricted",
]
}
Code Pack: API Script
# Create security classification custom properties for the organization
gh api --method POST \
"/orgs/${GITHUB_ORG}/properties/schema" \
--input - <<'JSON'
[
{
"property_name": "security-tier",
"value_type": "single_select",
"required": true,
"default_value": "standard",
"description": "Security classification tier",
"allowed_values": ["critical", "high", "standard", "low"]
},
{
"property_name": "data-classification",
"value_type": "single_select",
"required": true,
"default_value": "internal",
"description": "Data classification level",
"allowed_values": ["public", "internal", "confidential", "restricted"]
},
{
"property_name": "compliance-scope",
"value_type": "multi_select",
"required": false,
"description": "Applicable compliance frameworks",
"allowed_values": ["soc2", "pci-dss", "hipaa", "fedramp", "none"]
}
]
JSON
# Set custom property values on a specific repository
REPO="${GITHUB_REPO:-how-to-harden}"
gh api --method PATCH \
"/orgs/${GITHUB_ORG}/properties/values" \
--input - <<JSON
{
"repository_names": ["${REPO}"],
"properties": [
{"property_name": "security-tier", "value": "high"},
{"property_name": "data-classification", "value": "internal"},
{"property_name": "compliance-scope", "value": ["soc2"]}
]
}
JSON
Code Pack: Sigma Detection Rule
detection:
selection:
action:
- 'custom_property.update'
- 'custom_property_values.update'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Note: No Terraform provider support exists for delegated bypass configuration at this time.
Compliance Mappings
- CIS Controls: 16.12 (Implement code-level security checks)
- NIST 800-53: IA-5 (Authenticator management), CM-3 (Configuration change control)
- SOC 2: CC6.1
5.4 Define Custom Secret Scanning Patterns
Profile Level: L2 (Hardened) Requires: GitHub Advanced Security NIST 800-53: IA-5, SI-4
Description
Define organization-level custom secret scanning patterns to detect internal API keys, proprietary tokens, and other secrets specific to your organization that GitHub’s built-in patterns don’t cover. Custom patterns can also be configured for push protection (GA August 2025).
Rationale
Why Custom Patterns:
- GitHub’s built-in secret scanning covers 200+ provider patterns
- Internal API keys, service tokens, and custom credentials are not detected by default
- Custom patterns extend coverage to organization-specific secret formats
- Patterns can be enforced in push protection to block commits containing internal secrets
ClickOps Implementation
Step 1: Create Custom Pattern
- Organization Settings -> Code security -> Secret scanning
- Click “New pattern”
- Configure:
- Pattern name: e.g., “Internal API Key”
- Secret format: Regex pattern (e.g.,
internal_api_key_[a-zA-Z0-9]{32}) - Before secret: Optional context regex
- After secret: Optional context regex
- Test string: Provide sample matches for validation
- Click “Save and dry run” to test against existing repositories
Step 2: Enable in Push Protection
- After validating the pattern, edit it
- Enable “Include in push protection” (GA August 2025)
- This blocks commits containing matches for the custom pattern
Time to Complete: ~15 minutes per pattern
Code Implementation
Code Pack: API Script
# List existing custom secret scanning patterns for the organization
info "5.11 Listing custom secret scanning patterns..."
PATTERNS=$(gh_get "/orgs/${GITHUB_ORG}/secret-scanning/custom-patterns") || {
warn "5.11 Unable to list custom patterns (requires GHAS license)"
}
echo "${PATTERNS}" | jq '.[] | {name: .name, pattern: .pattern, scope: .scope, state: .state}'
# Create a custom secret scanning pattern for internal API keys
info "5.11 Creating custom secret scanning pattern..."
RESPONSE=$(gh_post "/orgs/${GITHUB_ORG}/secret-scanning/custom-patterns" '{
"name": "Internal API Key",
"pattern": "internal_api_key_[a-zA-Z0-9]{32}",
"secret_type": "custom_pattern",
"scope": "organization"
}') || {
warn "5.11 Unable to create custom pattern (may require GHAS license)"
}
pass "5.11 Custom secret scanning pattern created"
Compliance Mappings
- CIS Controls: 16.12 (Implement code-level security checks)
- NIST 800-53: IA-5 (Authenticator management), SI-4 (Information system monitoring)
- SOC 2: CC6.1
6. Dependency & Supply Chain Security
6.1 Enable Dependency Review for Pull Requests
Profile Level: L1 (Baseline) SLSA: Build L2 NIST 800-53: SA-12
Description
Automatically block pull requests that introduce vulnerable or malicious dependencies using the dependency-review-action. This applies to both package dependencies (npm, pip, go modules) and GitHub Actions dependencies — actions referenced in workflow files are also part of your supply chain.
Rationale
Attack Vector: Typosquatting, dependency confusion, compromised packages, compromised Actions
Real-World Incidents:
- event-stream (2018): Popular npm package hijacked, malicious code added to steal Bitcoin wallet credentials
- ua-parser-js (2021): Maintainer account compromised, cryptominer injected
- codecov (2021): Bash uploader modified to exfiltrate environment variables
- trivy-action (2026) / tj-actions (2025): GitHub Actions themselves are dependencies — when their tags were poisoned, every consuming workflow was compromised. Dependency review should cover Actions references alongside package manifests. See Section 3.10 for action-specific detection and Section 6.6 for incident response.
ClickOps Implementation
Step 1: Enable Dependency Graph
- Repository Settings -> Code security and analysis
- Enable Dependency graph (should already be enabled from Section 2.2)
Step 2: Add Dependency Review Action
Code Pack: CLI Script
name: Dependency Review
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Dependency Review
uses: actions/dependency-review-action@c74b580d73376b7750d3d2a50bfb8adc2c937507
with:
fail-on-severity: moderate
deny-licenses: GPL-2.0, GPL-3.0
Time to Complete: ~10 minutes
Code Implementation
Code Pack: API Script
# Manual dependency review
gh api /repos/{owner}/{repo}/dependency-graph/compare/main...feature-branch
# Check PR for new vulnerabilities
gh pr view 123 --json reviews
Code Pack: Terraform
resource "github_repository" "how_to_harden_vuln_alerts" {
name = var.repository_name
vulnerability_alerts = true
}
Code Pack: API Script
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET \
"${GH_API}/repos/${GITHUB_ORG}/${REPO}/vulnerability-alerts" \
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28")
# Enable Dependabot vulnerability alerts on the repository
info "4.01 Enabling vulnerability alerts..."
ENABLE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
"${GH_API}/repos/${GITHUB_ORG}/${REPO}/vulnerability-alerts" \
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28")
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_vulnerability_alerts.disable'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: API Script
# Audit: Check for critical and high severity open Dependabot alerts
info "4.02 Checking critical Dependabot alerts..."
CRITICAL_ALERTS=$(gh_get "/orgs/${GITHUB_ORG}/dependabot/alerts?state=open&severity=critical&per_page=100" 2>/dev/null) || {
warn "4.02 Unable to query Dependabot alerts (may require security_events scope)"
CRITICAL_ALERTS="[]"
}
CRITICAL_COUNT=$(echo "${CRITICAL_ALERTS}" | jq '. | length' 2>/dev/null || echo "0")
info "4.02 Checking high severity Dependabot alerts..."
HIGH_ALERTS=$(gh_get "/orgs/${GITHUB_ORG}/dependabot/alerts?state=open&severity=high&per_page=100" 2>/dev/null) || {
warn "4.02 Unable to query high severity Dependabot alerts"
HIGH_ALERTS="[]"
}
HIGH_COUNT=$(echo "${HIGH_ALERTS}" | jq '. | length' 2>/dev/null || echo "0")
info "4.02 Open alerts -- Critical: ${CRITICAL_COUNT}, High: ${HIGH_COUNT}"
# List affected repositories for critical alerts
if [ "${CRITICAL_COUNT}" -gt 0 ]; then
warn "4.02 Repositories with critical alerts:"
echo "${CRITICAL_ALERTS}" | jq -r '.[].repository.full_name' 2>/dev/null | sort -u | while read -r REPO_NAME; do
COUNT=$(echo "${CRITICAL_ALERTS}" | jq -r "[.[] | select(.repository.full_name == \"${REPO_NAME}\")] | length" 2>/dev/null)
warn "4.02 ${REPO_NAME}: ${COUNT} critical alert(s)"
done
fi
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repository_vulnerability_alert.dismiss'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Compliance Mappings
- SLSA: Build L2 (Dependency pinning)
- NIST 800-53: SA-12 (Supply chain protection)
- SOC 2: CC7.2
6.2 Pin Dependencies to Specific Versions (Hash Verification)
Profile Level: L2 (Hardened) SLSA: Build L3
Description
Pin all dependencies (npm, pip, go modules, etc.) to specific versions with hash verification. Prevents dependency confusion and version confusion attacks.
Rationale
Attack Prevention:
- Prevents automatic pulling of compromised new versions
- Hash verification ensures package hasn’t been tampered with
- Reproducible builds
ClickOps Implementation
Step 1: Review Current Dependencies
- Navigate to repository Insights -> Dependency graph
- Review all dependencies for version pinning status
Step 2: Enable Dependabot for Automated Pin Updates
- Navigate to repository Settings -> Code security and analysis
- Enable Dependabot version updates
Code Implementation
Code Pack: CLI Script
# Commit package-lock.json (contains hashes)
git add package-lock.json
# Verify integrity on install
npm ci --audit
# Generate requirements with hashes
pip-compile --generate-hashes requirements.in > requirements.txt
# Install with verification
pip install --require-hashes -r requirements.txt
# go.sum contains hashes automatically
go mod verify
# Bad: tag can change
# FROM node:18
# Good: digest is immutable
# FROM node:18@sha256:a1b2c3d4...
Automated Pinning:
Use Dependabot or Renovate to keep pins up-to-date while maintaining hash verification.
Code Pack: Terraform
resource "github_repository" "how_to_harden_wiki" {
name = var.repository_name
has_wiki = false
}
Code Pack: API Script
# Disable wiki on the repository
info "5.01 Disabling wiki on ${REPO}..."
RESPONSE=$(gh_patch "/repos/${GITHUB_ORG}/${REPO}" '{
"has_wiki": false
}') || {
fail "5.01 Failed to disable wiki on ${REPO}"
increment_failed
summary
exit 0
}
# Disable wiki on all repositories that have it enabled
for REPO_NAME in ${REPOS_WITH_WIKI}; do
info "5.01 Disabling wiki on ${REPO_NAME}..."
gh_patch "/repos/${GITHUB_ORG}/${REPO_NAME}" '{"has_wiki": false}' > /dev/null 2>&1 && {
pass "5.01 Wiki disabled on ${REPO_NAME}"
} || {
warn "5.01 Failed to disable wiki on ${REPO_NAME}"
}
done
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repo.update'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: Terraform
resource "github_repository" "how_to_harden" {
name = var.repository_name
delete_branch_on_merge = true
}
Code Pack: API Script
# Enable automatic branch deletion after pull request merge
info "5.02 Enabling delete branch on merge..."
RESPONSE=$(gh_patch "/repos/${GITHUB_ORG}/${REPO}" '{
"delete_branch_on_merge": true
}') || {
fail "5.02 Failed to enable delete branch on merge"
increment_failed
summary
exit 0
}
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'repo.update'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Compliance Mappings
- SLSA: Build L3 (Hermetic builds)
- NIST 800-53: SA-12
6.3 Configure Dependabot Grouped Security Updates
Profile Level: L1 (Baseline) NIST 800-53: SA-12, SI-2
Description
Configure Dependabot with grouped updates to reduce PR noise while keeping dependencies current. Group minor and patch updates by dependency type (production vs. development) to create fewer, more manageable pull requests.
Rationale
Why Grouped Updates:
- Individual PRs per dependency overwhelm teams with hundreds of PRs
- Grouped updates combine related updates into a single PR for easier review
- Reduces CI/CD load by running fewer pipeline executions
- Teams are more likely to review and merge timely updates
ClickOps Implementation
Step 1: Create Dependabot Configuration
- In your repository, create
.github/dependabot.yml - Define package ecosystems to monitor (npm, pip, GitHub Actions, etc.)
- Configure grouped updates with
groups:section
Step 2: Configure Groups
- Group by dependency type (
productionvsdevelopment) - Group by update type (
minorandpatchtogether,majorseparately) - Use patterns to group related packages (e.g., all
@aws-sdk/*packages)
Step 3: Set Limits
- Set
open-pull-requests-limitto control PR volume (default: 5) - Major version updates should remain individual PRs for careful review
Time to Complete: ~10 minutes
Code Implementation
Code Pack: CLI Script
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
production-dependencies:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
open-pull-requests-limit: 10
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
groups:
all-pip-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
Compliance Mappings
- NIST 800-53: SA-12 (Supply chain protection), SI-2 (Flaw remediation)
- SOC 2: CC7.1
6.4 Enable Build Provenance and npm Provenance
Profile Level: L2 (Hardened) SLSA: Build L2+ NIST 800-53: SA-12, SA-15
Description
Generate SLSA build provenance attestations for artifacts and publish npm packages with provenance. Build provenance creates a verifiable link between an artifact and its source code, build instructions, and build environment using Sigstore signing.
Rationale
Why Build Provenance:
- Consumers can verify artifacts were built from the claimed source code
- Detects tampering between build and distribution
- npm provenance connects packages to their source repository and CI/CD workflow
- Required for SLSA Build Level 2+ compliance
npm Trusted Publishing (2025+): When using OIDC-based trusted publishing, provenance attestations are automatically generated without requiring the --provenance flag, and long-lived npm tokens are eliminated entirely.
ClickOps Implementation
Step 1: Enable Artifact Attestations
- Repository Settings -> Code security and analysis
- Enable Artifact attestations (if not already enabled)
- For public repos, attestations use the public Sigstore instance
- For private repos, attestations use GitHub’s private Sigstore instance (requires Enterprise Cloud)
Step 2: Add Provenance to CI/CD
- Add
actions/attest-build-provenanceto your release workflow - For npm packages, add
--provenanceflag tonpm publish - Ensure workflow has
id-token: writeandattestations: writepermissions
Time to Complete: ~15 minutes
Code Implementation
npm Provenance Publishing:
Code Pack: CLI Script
name: Publish with Provenance
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
SLSA Build Provenance Attestation:
Code Pack: CLI Script
name: Build and Attest
on:
push:
tags:
- "v*"
permissions:
contents: read
id-token: write
attestations: write
jobs:
build-and-attest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Build artifact
run: |
mkdir -p dist
# Replace with your actual build command
tar -czf dist/release.tar.gz src/
- name: Generate SLSA provenance
uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4
with:
subject-path: "dist/release.tar.gz"
Compliance Mappings
- SLSA: Build L2 (Provenance), Build L3 (Signed provenance)
- NIST 800-53: SA-12 (Supply chain protection), SA-15 (Development process, standards, and tools)
- SOC 2: CC7.2
6.5 Enforce Dependency Review Across the Organization
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud NIST 800-53: SA-12, SA-11
Description
Use organization rulesets to enforce the dependency-review-action as a required workflow across all repositories. This ensures no repository can merge PRs with vulnerable dependencies without review, regardless of individual repository settings.
Rationale
Why Organization-Wide Enforcement:
- Individual repository setup is inconsistent – some repos may skip dependency review
- Organization rulesets enforce policies uniformly across all repos
- Prevents teams from disabling security checks on their own repositories
- Provides centralized visibility into dependency review compliance
ClickOps Implementation
Step 1: Create Required Workflow
- Create a
.github/workflows/dependency-review.ymlin a central repository (e.g.,.githubrepo) - Configure with your organization’s severity threshold and license policy
Step 2: Create Organization Ruleset
- Organization Settings -> Rules -> Rulesets
- Click “New ruleset” -> “New branch ruleset”
- Set target branches:
main,master - Set target repositories: All repositories (or select specific ones)
- Under Rules, add “Require workflows to pass before merging”
- Select the dependency-review workflow from your central repository
- Set enforcement to Active
Time to Complete: ~15 minutes
Code Implementation
Code Pack: API Script
# Create an organization ruleset that requires the dependency-review-action
info "6.06 Creating required workflow ruleset for dependency review..."
RESPONSE=$(gh_post "/orgs/${GITHUB_ORG}/rulesets" '{
"name": "Require Dependency Review",
"enforcement": "active",
"target": "branch",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/master"],
"exclude": []
},
"repository_name": {
"include": ["~ALL"],
"exclude": []
}
},
"rules": [
{
"type": "workflows",
"parameters": {
"workflows": [
{
"path": ".github/workflows/dependency-review.yml",
"repository_id": 0,
"ref": "refs/heads/main"
}
]
}
}
]
}') || {
warn "6.06 Unable to create ruleset (may already exist or require Enterprise Cloud)"
}
pass "6.06 Dependency review enforcement ruleset created"
Compliance Mappings
- NIST 800-53: SA-12 (Supply chain protection), SA-11 (Developer testing and evaluation)
- SOC 2: CC7.2
- SLSA: Build L2
6.6 Respond to CI/CD Supply Chain Compromises
Profile Level: L1 (Baseline) NIST 800-53: IR-4, IR-5, IR-6 CIS Controls: 17.1, 17.3
Description
Establish an incident response playbook for when a GitHub Action or CI/CD dependency is discovered to be compromised. Speed of response directly determines blast radius — the tj-actions compromise was active for 3+ days before detection, and the trivy-action poisoning affected builds within hours of tag manipulation.
Rationale
Why an IR Playbook for Actions:
- Compromised actions can exfiltrate every secret accessible to every workflow that uses them
- The blast radius grows exponentially with time — each workflow run exposes more credentials
- Standard application IR playbooks do not cover CI/CD-specific artifacts (workflow runs, OIDC tokens, artifact registries)
- Credential rotation must be comprehensive — any secret accessible to the affected workflow is potentially compromised, not just secrets explicitly used by the compromised action
Real-World Response Timelines:
- tj-actions/changed-files (March 2025): Malicious tag pushed ~March 14, detected ~March 16, GitHub removed action ~March 16. Window of exposure: ~3 days. StepSecurity Harden-Runner detected anomalous egress early but broad notification took days.
- trivy-action (March 2026): 75 tags poisoned on March 19, Socket.dev published advisory same day. The TeamPCP payload exfiltrated cloud credentials, SSH keys, and tokens to
scan.aquasecurtiy.org(typosquat domain). A fallback mechanism attempted to createtpcp-docsrepos in victim GitHub accounts.
ClickOps Implementation
Step 1: Immediate Triage (First 30 Minutes)
- Identify the compromised action name, affected versions/tags, and the advisory source
- Search all organization repositories for references to the compromised action
- Determine which workflows ran the compromised version and when
- Assess the secrets, OIDC roles, and artifact registries accessible to those workflows
Step 2: Containment (First 2 Hours)
- Pin the affected action to a known-good SHA in all repositories, or disable affected workflows entirely
- Block the compromised action version at the organization level (Actions allow-list)
- If using Harden-Runner, check the StepSecurity dashboard for anomalous egress from recent runs
- Disable any OIDC trust relationships that could be exploited with stolen tokens
Step 3: Credential Rotation (First 4 Hours)
- Use atomic rotation: revoke old credentials BEFORE issuing new ones. In the Trivy attack, non-atomic credential rotation after Phase 1 (February 2026) enabled Phase 2 (March 19) — attackers accessed refreshed tokens during the rotation window because old credentials were not revoked before new ones were issued. If a hard cutover is not feasible, set old credentials to expire within hours, not days.
- Rotate ALL organization-level secrets, not just those “used by” the compromised action
- Rotate ALL repository-level secrets in affected repositories
- Rotate ALL environment secrets accessible to affected workflows
- Revoke and regenerate any OIDC-based cloud role sessions (AWS STS, Azure, GCP)
- Rotate GitHub PATs, deploy keys, and app installation tokens that may have been exposed
- Rotate any package registry tokens (npm, PyPI, Docker Hub) accessible to workflows
- Check for service accounts with cross-organization access (see Section 1.8) — a compromised credential for a cross-org account extends the blast radius to every connected organization
Step 4: Check ALL Distribution Channels (Not Just Actions)
- Supply chain compromises often propagate beyond GitHub Actions — the Trivy v0.69.4 attack compromised the compiled binary itself, which propagated via Homebrew (auto-updated), Helm chart automation (bumped in PR), and documentation deployment systems
- Check package managers (Homebrew, apt, yum, Chocolatey) for compromised tool versions
- Check container registries for images built with the compromised tool — and for “ghost” images pushed directly without a build (e.g.,
aquasec/trivy:0.69.5and0.69.6were pushed to Docker Hub with no GitHub release, and0.69.6hijacked thelatesttag). See the Docker Hub Hardening Guide for detection scripts - Check Helm chart repositories for version bumps referencing compromised releases
- Homebrew executed an emergency downgrade for Trivy, reverting v0.69.4 to v0.69.3 with special CI labels to bypass normal audit
Step 5: Forensic Analysis
- Review workflow run logs for the compromised period — look for base64-encoded payloads, unexpected
curl/wgetcommands, or/procaccess patterns - Check GitHub audit logs for suspicious API calls during the compromise window
- Search for attacker-created repositories (e.g.,
tpcp-docsfor TeamPCP attacks) - Check artifact registries for builds produced during the compromise — these may contain backdoored code
- Review network logs for connections to known malicious domains (C2:
scan.aquasecurtiy.orgresolving to45.148.10.212) - Use incident-specific scanning tools when available (e.g.,
trivy-compromise-scannerwhich checks workflow runs against known-compromised commit SHAs)
Step 6: Anticipate Anti-Response TTPs
- In the Trivy attack, the attacker deleted the original incident disclosure discussion (#10265) to slow community response — monitor for deletion of security-related issues and discussions
- The attacker deployed 17 coordinated spam bot accounts that posted generic praise comments within a single second to bury the legitimate security discussion (#10420) — be prepared for discussion flooding as an obstruction technique
- Maintain out-of-band communication channels (Slack, email lists) for incident coordination — do not rely solely on GitHub Discussions for security response
Step 7: Recovery and Communication
- Verify all secrets have been rotated and test that systems function with new credentials
- Re-enable workflows with the action pinned to a verified-safe SHA
- If your organization publishes packages or actions consumed by others, notify downstream users that builds during the compromise window may be tainted
- File a GitHub Advisory if you discover new IOCs
- Conduct a post-incident review — update your action allow-list and SHA pinning practices
Time to Complete: Initial triage ~30 minutes; full response ~4-8 hours depending on organization size
Code Implementation
Code Pack: CLI Script
# Usage: ./hth-github-6.07-supply-chain-compromise-response.sh <action> [org]
# Example: ./hth-github-6.07-supply-chain-compromise-response.sh aquasecurity/trivy-action myorg
COMPROMISED_ACTION="${1:-aquasecurity/trivy-action}"
ORG="${2:-$(gh api user --jq '.login')}"
echo "=== Supply Chain Compromise Response ==="
echo "Scanning org '$ORG' for: $COMPROMISED_ACTION"
# Step 1: Find affected repositories
echo ""
echo "--- Affected Repositories ---"
gh api --paginate "/orgs/$ORG/repos" --jq '.[].full_name' | while read -r repo; do
result=$(gh api "repos/$repo/git/trees/HEAD?recursive=1" \
--jq '.tree[] | select(.path | startswith(".github/workflows/")) | .path' 2>/dev/null || true)
for workflow in $result; do
content=$(gh api "repos/$repo/contents/$workflow" \
--jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)
if echo "$content" | grep -qi "$COMPROMISED_ACTION"; then
echo " AFFECTED: $repo ($workflow)"
fi
done
done
# Step 2: List secrets requiring rotation
echo ""
echo "--- Secrets Requiring Immediate Rotation ---"
echo "Organization secrets:"
gh api "/orgs/$ORG/actions/secrets" \
--jq '.secrets[] | " - \(.name) (updated: \(.updated_at))"' 2>/dev/null \
|| echo " (requires admin access)"
echo ""
echo "Check each affected repo: gh api /repos/OWNER/REPO/actions/secrets"
# Step 3: TeamPCP-specific indicators
echo ""
echo "--- TeamPCP Exfiltration Indicators ---"
gh api "/orgs/$ORG/repos" --jq '.[].name' 2>/dev/null | \
grep -i "tpcp" && echo " ALERT: Found tpcp exfil repo!" || \
echo " OK: No tpcp-docs repos found"
echo ""
echo "Check network logs for:"
echo " - scan.aquasecurtiy.org (Trivy typosquat, resolves to 45.148.10.212)"
echo " - *.gist.githubusercontent.com (tj-actions exfil)"
echo ""
echo "Known compromised commit SHAs (Trivy March 2026):"
echo " - setup-trivy: 8afa9b9f9183b4e00c46e2b82d34047e3c177bd0"
echo " - trivy-action: ddb9da4 (and all tags except v0.62.1)"
echo ""
echo "Check package managers for compromised tool versions:"
echo " - Homebrew: brew info trivy (v0.69.4 was malicious)"
echo " - Helm charts: check for automated version bump PRs"
echo " - Container images: check builds using trivy during compromise window"
# Step 4: Containment actions
echo ""
echo "--- Immediate Containment ---"
echo "1. Pin to known-good SHA: npx pin-github-action .github/workflows/*.yml"
echo "2. Disable workflows: gh workflow disable WORKFLOW --repo OWNER/REPO"
echo "3. Rotate ALL secrets (org + repo + environment)"
echo "4. Revoke OIDC cloud sessions (AWS STS, Azure, GCP)"
echo "5. Audit artifact registries for tainted builds"
echo "6. Notify downstream consumers"
Validation & Testing
- IR playbook is documented and accessible to the security team
- Audit script can enumerate all repos using a specific action across the org
- Secret rotation runbook covers org, repo, and environment secrets
- Team has practiced the playbook with a tabletop exercise
- Harden-Runner or equivalent provides runtime egress alerting
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC7.3, CC7.4 | Incident response, incident recovery |
| NIST 800-53 | IR-4, IR-5, IR-6 | Incident handling, monitoring, reporting |
| ISO 27001 | A.16.1.5 | Response to information security incidents |
| CIS Controls | 17.1, 17.3 | Incident response process, incident response exercises |
7. Modern Platform Features
7.1 Configure Copilot Governance
Profile Level: L1 (Baseline) Requires: GitHub Copilot Business or Enterprise NIST 800-53: AC-3, AU-2
Description
Configure Copilot governance policies including content exclusions to prevent Copilot from accessing sensitive files, enable audit logging for Copilot usage, and manage Copilot feature policies at the organization and enterprise level.
Rationale
Why Copilot Governance Matters:
- Copilot reads file context to generate suggestions – sensitive files (secrets, credentials, PII) should be excluded
- Content exclusions prevent Copilot from processing files matching specified patterns
- Audit logging tracks Copilot usage for compliance and security monitoring
- Enterprise-level policies (GA February 2026) control which Copilot features are available
Content Exclusion Scope (ISC: Copilot Content Exclusions):
- IDE-level content exclusions are GA
- GitHub.com content exclusions are in preview (January 2025)
- Exclusions prevent Copilot from using file content for suggestions but do not prevent developers from opening the files
- Organizations MUST configure content exclusions for sensitive paths (secrets, credentials, PII) before enabling Copilot
ClickOps Implementation
Step 1: Configure Copilot Policies
- Navigate to: Organization Settings -> Copilot -> Policies
- Configure feature access:
- Suggestions matching public code: Block or allow
- Chat in IDE: Enable or disable
- Chat in GitHub.com: Enable or disable
- CLI: Enable or disable
Step 2: Set Content Exclusions
- Organization Settings -> Copilot -> Content exclusion
- Add paths to exclude from Copilot context:
**/.env*– Environment files**/secrets/**– Secret directories**/*.pem– Certificate files**/*.key– Private keys**/credentials/**– Credential files
- Apply per-repository or organization-wide
Step 3: Review Audit Logs
- Organization Settings -> Audit log
- Filter by
action:copilotto see Copilot-related events - Monitor for unusual Copilot usage patterns
Time to Complete: ~15 minutes
Code Implementation
Code Pack: API Script
# Get current Copilot organization settings
info "7.02 Retrieving Copilot organization settings..."
SETTINGS=$(gh_get "/orgs/${GITHUB_ORG}/copilot/billing") || {
warn "7.02 Unable to retrieve Copilot settings (requires manage_billing:copilot scope)"
}
echo "${SETTINGS}" | jq '{
seat_breakdown: .seat_breakdown,
public_code_suggestions: .public_code_suggestions,
ide_chat: .ide_chat,
platform_chat: .platform_chat,
cli: .cli
}'
# Get and set Copilot content exclusion rules
info "7.02 Retrieving Copilot content exclusion rules..."
EXCLUSIONS=$(gh_get "/orgs/${GITHUB_ORG}/copilot/content_exclusions") || {
warn "7.02 Unable to retrieve content exclusions"
}
echo "${EXCLUSIONS}" | jq '.'
# Set content exclusion rules to protect sensitive paths
info "7.02 Setting Copilot content exclusion rules..."
gh_put "/orgs/${GITHUB_ORG}/copilot/content_exclusions" '[
{
"repository": "*",
"paths": [
"**/.env*",
"**/secrets/**",
"**/credentials/**",
"**/*secret*",
"**/*credential*",
"**/*.pem",
"**/*.key"
]
}
]' || {
warn "7.02 Unable to set content exclusion rules"
}
pass "7.02 Copilot content exclusion rules configured"
# Audit Copilot usage via the audit log
info "7.02 Querying Copilot audit events..."
gh_get "/orgs/${GITHUB_ORG}/audit-log?phrase=action:copilot&per_page=25" \
| jq '.[] | {action: .action, actor: .actor, created_at: .created_at}' || {
warn "7.02 Unable to query Copilot audit events"
}
Note: No Terraform provider support exists for Copilot policies at this time.
Compliance Mappings
- CIS Controls: 3.3 (Configure data access control lists)
- NIST 800-53: AC-3 (Access enforcement), AU-2 (Audit events)
- SOC 2: CC6.1
7.2 Create Custom Repository Roles
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud NIST 800-53: AC-2, AC-3 CIS Controls: 6.8
Description
Create custom repository roles to define fine-grained permission sets beyond the built-in Read, Triage, Write, Maintain, and Admin roles. Custom roles allow precise access control – for example, a “Security Reviewer” role that can view and dismiss security alerts without write access to code.
Rationale
Why Custom Roles:
- Built-in roles don’t cover all access patterns (e.g., security-only access)
- Reduces over-permissioning by providing exact permissions needed
- Supports separation of duties between development and security teams
- Each custom role inherits from a base role (Read, Triage, Write, or Maintain) and adds specific permissions
ClickOps Implementation
Step 1: Create Custom Role
- Navigate to: Organization Settings -> Repository roles
- Click “Create a role”
- Set role name and description (e.g., “Security Reviewer”)
- Select base role (e.g., Read)
- Add additional permissions:
- Security events – View and manage security alerts
- View secret scanning alerts – See detected secrets
- Dismiss secret scanning alerts – Close false positives
- View Dependabot alerts – See vulnerable dependencies
- Dismiss Dependabot alerts – Close resolved alerts
- Click “Create role”
Step 2: Assign Custom Role
- Repository Settings -> Collaborators and teams
- Add team or user
- Select the custom role from the dropdown
Time to Complete: ~10 minutes per role
Code Implementation
Code Pack: API Script
# List existing custom repository roles
info "7.03 Listing custom repository roles..."
ROLES=$(gh_get "/orgs/${GITHUB_ORG}/custom-repository-roles") || {
warn "7.03 Unable to list custom roles (requires Enterprise Cloud)"
}
echo "${ROLES}" | jq '.custom_roles[] | {id: .id, name: .name, base_role: .base_role, permissions: .permissions}'
# Create a Security Reviewer custom role
info "7.03 Creating Security Reviewer custom role..."
RESPONSE=$(gh_post "/orgs/${GITHUB_ORG}/custom-repository-roles" '{
"name": "Security Reviewer",
"description": "Can view security alerts and manage security settings",
"base_role": "read",
"permissions": [
"security_events",
"view_secret_scanning_alerts",
"dismiss_secret_scanning_alerts",
"view_dependabot_alerts",
"dismiss_dependabot_alerts",
"view_code_scanning_alerts",
"dismiss_code_scanning_alerts"
]
}') || {
warn "7.03 Unable to create custom role (may already exist)"
}
pass "7.03 Security Reviewer role created"
Compliance Mappings
- CIS Controls: 6.8 (Define and maintain role-based access control)
- NIST 800-53: AC-2 (Account management), AC-3 (Access enforcement)
- SOC 2: CC6.1, CC6.2
7.3 Enforce Required Workflows via Organization Rulesets
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud NIST 800-53: SA-11, CM-3
Description
Use organization rulesets to enforce required workflows (security scans, code quality checks, dependency review) across all or selected repositories. This ensures security gates cannot be bypassed at the repository level.
Rationale
Why Required Workflows:
- Repository-level branch protection can be disabled by repo admins
- Organization rulesets are managed centrally and cannot be overridden by repo admins
- Ensures consistent security checks across the entire organization
- Supports compliance by guaranteeing that all code changes pass required checks
ClickOps Implementation
Step 1: Prepare Central Workflows
- Create a
.githubrepository in your organization (if it doesn’t exist) - Add required workflow files (e.g.,
security-scan.yml,dependency-review.yml) - These workflows will be referenced by the organization ruleset
Step 2: Create Organization Ruleset
- Navigate to: Organization Settings -> Rules -> Rulesets
- Click “New ruleset” -> “New branch ruleset”
- Set name: “Required Security Workflows”
- Set enforcement: Active
- Set target branches:
refs/heads/main,refs/heads/master - Set target repositories: All repositories (exclude
.githubrepo) - Under Rules, add “Require workflows to pass before merging”
- Select each required workflow and its source repository
- Click “Create”
Step 3: Test Enforcement
- Create a test PR in a target repository
- Verify the required workflow runs automatically
- Confirm the PR cannot be merged until the workflow passes
Time to Complete: ~20 minutes
Code Implementation
Code Pack: API Script
# Create an organization ruleset that enforces required workflows
info "7.04 Creating required workflow ruleset..."
RESPONSE=$(gh_post "/orgs/${GITHUB_ORG}/rulesets" '{
"name": "Required Security Workflows",
"enforcement": "active",
"target": "branch",
"conditions": {
"ref_name": {
"include": ["refs/heads/main", "refs/heads/master"],
"exclude": []
},
"repository_name": {
"include": ["~ALL"],
"exclude": ["*.github"]
}
},
"rules": [
{
"type": "workflows",
"parameters": {
"workflows": [
{
"path": ".github/workflows/security-scan.yml",
"repository_id": 0,
"ref": "refs/heads/main"
},
{
"path": ".github/workflows/dependency-review.yml",
"repository_id": 0,
"ref": "refs/heads/main"
}
]
}
}
]
}') || {
warn "7.04 Unable to create required workflow ruleset"
}
pass "7.04 Required workflow ruleset created"
# List existing rulesets
info "7.04 Current organization rulesets:"
gh_get "/orgs/${GITHUB_ORG}/rulesets" \
| jq '.[] | {name: .name, enforcement: .enforcement, id: .id}'
Note: No Terraform provider support exists for required workflow rulesets at this time.
Compliance Mappings
- CIS Controls: 16.12 (Implement code-level security checks)
- NIST 800-53: SA-11 (Developer testing and evaluation), CM-3 (Configuration change control)
- SOC 2: CC7.1, CC8.1
8. Monitoring & Audit Logging
8.1 Enable Audit Log Streaming to SIEM
Profile Level: L2 (Hardened) Requires: GitHub Enterprise Cloud
Description
Stream GitHub audit logs to your SIEM (Splunk, Datadog, AWS Security Lake) for centralized monitoring and alerting.
Rationale
Detection Use Cases:
- Unusual authentication patterns (brute force, credential stuffing)
- Privilege escalation (user added to admin team)
- Data exfiltration (bulk repository clones)
- Supply chain attacks (malicious workflow modifications)
ClickOps Implementation
- Enterprise Settings -> Audit log -> Log streaming
- Click “Configure stream”
- Choose destination:
- Amazon S3
- Azure Blob Storage
- Azure Event Hubs
- Datadog
- Google Cloud Storage
- Splunk (HTTP Event Collector)
- Configure endpoint details
- Enable Git events for repository activity
- Select event categories to stream
- Test connection
- Enable stream
Time to Complete: ~30 minutes
Code Implementation
Code Pack: API Script
# Enable audit log streaming via API (Enterprise Cloud)
info "5.07 Configuring audit log streaming for ${GITHUB_ORG}..."
STREAM_RESPONSE=$(gh_post "/orgs/${GITHUB_ORG}/audit-log/streams" "{
\"enabled\": true,
\"stream_type\": \"Splunk\",
\"vendor_specific\": {
\"domain\": \"${SPLUNK_HEC_ENDPOINT:-https://splunk.example.com:8088}\",
\"token\": \"${SPLUNK_HEC_TOKEN:-YOUR_HEC_TOKEN}\"
}
}") || {
fail "5.07 Failed to configure audit log streaming"
increment_failed
summary
exit 0
}
pass "5.07 Audit log streaming configured"
Code Pack: Sigma Detection Rule
detection:
selection:
action:
- 'audit_log_streaming.create'
- 'audit_log_streaming.update'
- 'audit_log_streaming.destroy'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Code Pack: API Script
# Audit: Check all org webhooks for insecure HTTP URLs and missing secrets
HOOKS=$(gh_get "/orgs/${GITHUB_ORG}/hooks") || {
fail "5.03 Unable to retrieve org webhooks (may require admin:org_hook scope)"
increment_failed
summary
exit 0
}
TOTAL_HOOKS=$(echo "${HOOKS}" | jq '. | length' 2>/dev/null || echo "0")
info "5.03 Found ${TOTAL_HOOKS} organization webhook(s)"
ISSUES=0
if [ "${TOTAL_HOOKS}" -gt 0 ]; then
# Check for HTTP (non-HTTPS) webhook URLs
HTTP_HOOKS=$(echo "${HOOKS}" | jq '[.[] | select(.config.url | test("^http://"))] | length' 2>/dev/null || echo "0")
if [ "${HTTP_HOOKS}" -gt 0 ]; then
fail "5.03 ${HTTP_HOOKS} webhook(s) use insecure HTTP (should use HTTPS)"
echo "${HOOKS}" | jq -r '.[] | select(.config.url | test("^http://")) | " ID: \(.id) | URL: \(.config.url)"' 2>/dev/null
ISSUES=$((ISSUES + HTTP_HOOKS))
fi
# Check for webhooks without secrets configured
NO_SECRET=$(echo "${HOOKS}" | jq '[.[] | select(.config.secret == null or .config.secret == "")] | length' 2>/dev/null || echo "0")
if [ "${NO_SECRET}" -gt 0 ]; then
warn "5.03 ${NO_SECRET} webhook(s) have no secret configured"
echo "${HOOKS}" | jq -r '.[] | select(.config.secret == null or .config.secret == "") | " ID: \(.id) | URL: \(.config.url)"' 2>/dev/null
ISSUES=$((ISSUES + NO_SECRET))
fi
# List all webhooks for review
info "5.03 All webhooks:"
echo "${HOOKS}" | jq -r '.[] | " ID: \(.id) | URL: \(.config.url) | Active: \(.active) | Events: \(.events | join(", "))"' 2>/dev/null
fi
Code Pack: Sigma Detection Rule
detection:
selection:
action: 'hook.create'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Note: No Terraform provider support exists for audit log streaming configuration at this time.
Key Events to Monitor
These events should be prioritized in your SIEM alert rules:
| Event | Description | Alert Priority |
|---|---|---|
org.add_member |
New member added to org | Medium |
repo.destroy |
Repository deleted | High |
protected_branch.destroy |
Branch protection removed | Critical |
protected_branch.policy_override |
Admin bypassed branch protection | High |
oauth_authorization.create |
New OAuth app authorized | Medium |
personal_access_token.create |
New PAT created | Medium |
copilot.content_exclusion_changed |
Copilot exclusion rules modified | Medium |
secret_scanning_push_protection.bypass |
Push protection bypassed | High |
org.update_member_repository_creation_permission |
Repo creation permissions changed | Medium |
Detection Queries
Code Pack: DB Query
index=github action=org.add_member
| stats count by actor, user
| where count > 5
index=github action=git.clone
| stats dc(repo) as unique_repos by actor
| where unique_repos > 50
index=github action=secret_scanning.dismiss_alert
| table _time, actor, repo, alert_id
index=github action=protected_branch.destroy
| table _time, actor, repo, branch
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC7.2 | System monitoring |
| NIST 800-53 | AU-2, AU-6 | Audit events, audit review and analysis |
| ISO 27001 | A.12.4.1 | Event logging |
| CIS Controls | 8.2 | Collect audit logs |
8.2 Use Security Overview Dashboard
Profile Level: L1 (Baseline) Requires: GitHub Enterprise Cloud with GHAS NIST 800-53: RA-5, SI-4
Description
Use the Security Overview dashboard to get a consolidated view of security alert status across all repositories in the organization. Monitor Dependabot, secret scanning, and code scanning alerts from a single interface with filtering and trend analysis.
Rationale
Why Security Overview:
- Provides organization-wide visibility into security posture without checking each repo individually
- Tracks alert trends over time to measure security improvement
- Identifies repositories with the most critical vulnerabilities
- Enables security teams to prioritize remediation efforts
ClickOps Implementation
Step 1: Access Security Overview
- Navigate to: Organization page -> Security tab
- Review the overview dashboard showing:
- Total open alerts by type (Dependabot, secret scanning, code scanning)
- Alert trends over time
- Repositories with most alerts
Step 2: Filter and Prioritize
- Filter by severity: Focus on Critical and High alerts first
- Filter by repository: Identify highest-risk repositories
- Filter by alert type: Address secret scanning alerts immediately (active credentials)
Step 3: Export and Report
- Use the Security Overview API to extract metrics for reporting
- Track key metrics: mean time to remediation, open critical alerts, coverage percentage
Time to Complete: ~5 minutes for initial review, ongoing monitoring
Code Implementation
Code Pack: API Script
# Query organization-wide security alert summary
info "8.01 Querying Dependabot alerts summary..."
DEPENDABOT=$(gh_get "/orgs/${GITHUB_ORG}/dependabot/alerts?state=open&severity=critical,high&per_page=100") || {
warn "8.01 Unable to query Dependabot alerts"
}
DEPENDABOT_COUNT=$(echo "${DEPENDABOT}" | jq 'length' 2>/dev/null || echo "0")
info "8.01 Querying secret scanning alerts summary..."
SECRETS=$(gh_get "/orgs/${GITHUB_ORG}/secret-scanning/alerts?state=open&per_page=100") || {
warn "8.01 Unable to query secret scanning alerts"
}
SECRET_COUNT=$(echo "${SECRETS}" | jq 'length' 2>/dev/null || echo "0")
info "8.01 Querying code scanning alerts summary..."
CODE=$(gh_get "/orgs/${GITHUB_ORG}/code-scanning/alerts?state=open&severity=critical,error&per_page=100") || {
warn "8.01 Unable to query code scanning alerts"
}
CODE_COUNT=$(echo "${CODE}" | jq 'length' 2>/dev/null || echo "0")
echo ""
echo "Security Overview Summary for ${GITHUB_ORG}:"
echo " Dependabot (critical/high): ${DEPENDABOT_COUNT}"
echo " Secret scanning (open): ${SECRET_COUNT}"
echo " Code scanning (critical): ${CODE_COUNT}"
# Query audit log for security-relevant events
info "8.01 Querying recent security audit events..."
gh_get "/orgs/${GITHUB_ORG}/audit-log?phrase=action:protected_branch+action:org.update_member&per_page=25" \
| jq '.[] | {action: .action, actor: .actor, created_at: .created_at, repo: .repo}' || {
warn "8.01 Unable to query audit log"
}
Compliance Mappings
- CIS Controls: 7.5 (Perform automated vulnerability scans)
- NIST 800-53: RA-5 (Vulnerability monitoring and scanning), SI-4 (Information system monitoring)
- SOC 2: CC7.1, CC7.2
8.3 Apply GitHub-Recommended Security Configuration
Profile Level: L1 (Baseline) Requires: GitHub Enterprise Cloud
Description
Apply GitHub’s code security configurations to all repositories in the organization. Security configurations (GA July 2024) are named profiles that bundle security feature settings and can be applied to repository groups for consistent coverage.
Rationale
Why Security Configurations:
- Ensures no repository is left without basic security features
- Named profiles allow different tiers (e.g., “Standard” and “High Security”)
- New repositories automatically receive the assigned configuration
- Custom configurations can layer stricter requirements on top of GitHub’s defaults
ClickOps Implementation
Step 1: Access Security Configurations
- Navigate to: Organization Settings -> Code security -> Configurations
Step 2: Apply GitHub Recommended
- Select GitHub recommended configuration
- Review included settings:
- Dependency graph
- Dependabot alerts and security updates
- Secret scanning and push protection
- Code scanning (default setup)
- Apply to all repositories
Step 3: Create Custom Configuration (Optional)
- For stricter requirements, click “New configuration”
- Name it (e.g., “High Security”)
- Enable additional settings:
- Grouped security updates
- Custom secret scanning patterns
- Security-extended CodeQL queries
- Non-provider pattern scanning
- Apply to specific repository sets based on sensitivity
Time to Complete: ~15 minutes
Code Implementation
Code Pack: API Script
# List available security configurations
info "5.08 Listing security configurations for ${GITHUB_ORG}..."
CONFIGS=$(gh_get "/orgs/${GITHUB_ORG}/code-security/configurations") || {
fail "5.08 Unable to retrieve security configurations"
increment_failed
summary
exit 0
}
echo "${CONFIGS}" | jq '.[] | {name: .name, id: .id, target_type: .target_type}'
# Apply configuration to all repositories
CONFIG_ID=$(echo "${CONFIGS}" | jq -r '.[0].id // empty')
if [ -n "${CONFIG_ID}" ]; then
info "5.08 Applying configuration ${CONFIG_ID} to all repositories..."
gh_post "/orgs/${GITHUB_ORG}/code-security/configurations/${CONFIG_ID}/attach" '{"scope": "all"}' || {
fail "5.08 Failed to apply security configuration"
increment_failed
summary
exit 0
}
pass "5.08 Security configuration applied to all repositories"
fi
Code Pack: Sigma Detection Rule
detection:
selection:
action:
- 'org.enable_security_configuration'
- 'org.disable_security_configuration'
- 'org.update_security_configuration'
condition: selection
fields:
- actor
- action
- org
- repo
- created_at
Note: No Terraform provider support exists for code security configurations at this time.
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC7.1 | Configuration management |
| NIST 800-53 | CM-6 | Configuration settings |
| ISO 27001 | A.12.1.1 | Documented operating procedures |
| CIS Controls | 4.1 | Secure configuration of enterprise assets |
9. Third-Party Integration Security
9.1 Integration Risk Assessment Matrix
Before allowing any third-party integration access to GitHub, assess risk:
| Risk Factor | Low (1 pt) | Medium (2 pts) | High (3 pts) |
|---|---|---|---|
| Repository Access | Public repos only | Read private repos | Write access to repos |
| OAuth Scopes | read:user, public_repo |
repo:status, read:org |
repo, admin:org |
| Token Lifetime | Session-based (hours) | Days | Persistent/no expiration |
| Vendor Security | SOC 2 Type II, pen tested | SOC 2 Type I | No certifications |
| Data Sensitivity | Non-production data | Some prod access | Full prod/secrets access |
| Runner Access | None | GitHub-hosted only | Self-hosted runners |
Decision Matrix:
- 6-8 points: Approve with standard OAuth scope restrictions
- 9-12 points: Approve with enhanced monitoring + restricted repos
- 13-18 points: Require security review, minimize scope, or reject
9.2 Common Integrations and Recommended Controls
CI/CD Platforms (CircleCI, Jenkins, Travis CI)
Data Access: High (read/write repos, access to secrets)
Recommended Controls:
- Use GitHub App integration (scoped permissions) instead of OAuth tokens
- Restrict to specific repositories (not all org repos)
- Use OIDC for cloud credentials (see Section 5.2) – never store long-lived cloud keys
- Note: CircleCI was breached in January 2023 – high-risk integration
- Rotate any remaining OAuth tokens quarterly
- Monitor audit logs for bulk repository access and unusual clone patterns
Dependency Scanning (Snyk, Mend, Socket)
Data Access: Medium (read repos, write security alerts)
Recommended Controls:
- Use vendor’s GitHub App (scoped permissions) instead of OAuth app
- Scope to repos with actual dependencies (not docs/config repos)
- Review security alerts weekly – don’t let them accumulate
- For Snyk: OAuth scope
repo:read,security_events:write
Dependabot / Renovate (Dependency Updates)
Data Access: Medium-High (read repos, create PRs, update dependencies)
Recommended Controls:
- Use GitHub-native Dependabot (preferred – built-in, no external access)
- If using Renovate: Scope to
repoonly, self-host if possible - Require PR reviews for dependency update PRs (don’t auto-merge major versions)
- Enable branch protection to require status checks before merge
Communication (Slack, Microsoft Teams)
Data Access: Low-Medium (read repos, post notifications)
Recommended Controls:
- Use the vendor’s GitHub App with narrow repository selection
- Avoid granting write permissions
- Filter notifications to avoid leaking sensitive data in public channels
- Review connected channels quarterly
Code Quality (SonarQube, SonarCloud, CodeClimate)
Data Access: Medium (read repos, write analysis results)
Recommended Controls:
- Use vendor’s GitHub App for scoped permissions
- OAuth scope:
repo:read,statuses:write,checks:write - Ensure the tool doesn’t store secrets from scanned code
- Review code analysis results before merging
Security Tooling (OpenSSF Scorecard, StepSecurity, Allstar)
Data Access: Low-Medium (read repos, write check results)
Recommended Controls:
- OpenSSF Scorecard: Use
ossf/scorecard-actionto assess repository security posture - StepSecurity Harden-Runner: Use
step-security/harden-runnerto detect and block exfiltration from GitHub Actions workflows - Allstar: Install the Allstar GitHub App to enforce security policies across repos
- These tools improve security posture – prioritize adoption over risk mitigation
Self-Hosted Runner Risk: Research in 2024 found 43,803 public repositories with exposed self-hosted runners. If your integrations use self-hosted runners, ensure they are ephemeral (see Section 3.4) and restricted to private repository workflows only.
Appendix A: Edition Compatibility
| Feature | GitHub Free | GitHub Team | Enterprise Cloud | Enterprise Server |
|---|---|---|---|---|
| 2FA Enforcement | Yes | Yes | Yes | Yes |
| Branch Protection | Basic | Yes | Yes (advanced) | Yes (advanced) |
| Repository Rulesets | No | No | Yes | Yes |
| SAML SSO | No | No | Yes | Yes |
| SCIM Provisioning | No | No | Yes | Yes |
| IP Allow List | No | No | Yes | Yes |
| Secret Protection ($19/committer/mo) | Public repos | Public repos | Yes | Yes (add-on) |
| Code Security ($30/committer/mo) | Public repos | Public repos | Yes | Yes (add-on) |
| Push Protection | Public repos | Public repos | Yes | Yes |
| Custom Secret Patterns | No | No | Yes | Yes |
| Delegated Bypass for Push Protection | No | No | Yes | Yes |
| Dependency Review Action | Yes | Yes | Yes | Yes |
| Copilot (Business/Enterprise) | No | No | Yes | Yes |
| Custom Repository Roles | No | No | Yes | No |
| Audit Log Streaming | No | No | Yes | Yes |
| Required Workflows (org rulesets) | No | No | Yes | Yes |
| Security Overview Dashboard | No | No | Yes | Yes |
| Self-Hosted Runner Groups | No | No | Yes | Yes |
Appendix B: References
Official GitHub Documentation:
- GitHub Enterprise Cloud Trust Center
- GitHub Copilot Trust Center
- GitHub Docs
- Enterprise Cloud Documentation
- Best Practices for Securing Accounts
- Configuring SAML SSO for Your Enterprise
- SAML SSO for Enterprise Managed Users
- SAML Configuration Reference
- Hardening security for your enterprise
- Security hardening for GitHub Actions
- GitHub Advanced Security
- Accessing Compliance Reports
API & Developer Documentation:
- REST API Reference
- Enterprise Cloud REST API
- GraphQL API
- GitHub CLI (gh)
- Accessing Compliance Reports for Your Enterprise
GHAS Product Changes (April 2025):
- Secret Protection – $19/committer/month (standalone)
- Code Security – $30/committer/month (standalone)
- GHAS Unbundling Announcement
Supply Chain Security Frameworks:
- SLSA Framework
- SLSA GitHub Generator
- OpenSSF Supply Chain Integrity WG
- Achieving SLSA 3 with GitHub Actions
- npm Provenance
- GitHub Attestations
Copilot Governance:
Compliance Frameworks:
- SOC 1 Type II, SOC 2 Type II, ISO/IEC 27001:2013, CSA CAIQ Level 1, CSA STAR Level 2 – via Trust Center
- GitHub Copilot included in SOC 2 Type 1 and ISO 27001 certification scope (June 2024)
Security Incidents:
- February–March 2026 TeamPCP / Aqua Security Trivy Compromise (Three-Phase Campaign): Most sophisticated GitHub supply chain attack to date — a cascading campaign across GitHub Actions, Docker Hub, npm, VS Code extensions, and Kubernetes. GHSA-69fq-xp46-6x23. See Sections 1.8, 3.8, 3.9, 3.10, and 6.6 for hardening controls.
- Phase 1 (Feb 27–28): AI-powered bot
hackerbot-clawexploitedpull_request_targetmisconfiguration to steal a PAT, privatize the trivy repo, delete 178 releases, and push malicious VS Code extension versions to OpenVSX that spawned AI coding tools in permissive modes. - Phase 2 (Mar 19): Non-atomic credential rotation enabled TeamPCP to access refreshed tokens, poisoning 75/76 trivy-action tags and the v0.69.4 binary with a three-stage credential stealer that scraped
/proc/*/memand exfiltrated toscan.aquasecurtiy.org. - Phase 3 (Mar 22): Cross-org
Argon-DevOps-Mgtservice account used to deface all 44 internalaquasec-comrepos in a 2-minute burst, push malicious Docker Hub images v0.69.5/v0.69.6 (v0.69.6 hijackedlatesttag), deploy self-propagating npm worm (CanisterWorm— 141 packages, ICP blockchain C2), and launch geotargeted Kubernetes wiper.
- Phase 1 (Feb 27–28): AI-powered bot
- March 2025 tj-actions/changed-files Compromise: Supply chain attack modified the popular GitHub Action (23,000+ repositories), retroactively repointing version tags to a malicious commit that exfiltrated CI/CD secrets from workflow logs.
- March-June 2025 Salesloft/Drift Breach (UNC6395): Threat actor accessed Salesloft GitHub account, downloaded repository content, and established workflows – affecting 700+ organizations including Cloudflare, Zscaler, and Palo Alto Networks.
- November 2025 Service Outage: Expired internal TLS certificate caused failures on all Git operations.
- Code Signing Certificate Theft (January 2023): Attacker used a compromised PAT to access GitHub repositories and steal encrypted code-signing certificates for GitHub Desktop and Atom. Certificates were revoked February 2, 2023.
- Fake Dependabot Commits (July 2023): Stolen GitHub PATs used to inject malicious commits disguised as Dependabot contributions across hundreds of public and private repositories.
- CircleCI Security Incident (January 2023) – OAuth tokens stolen, used to access customer GitHub repositories.
- Codecov Bash Uploader Compromise (April 2021) – Modified uploader exfiltrated environment variables from CI/CD pipelines.
- Heroku/Travis CI GitHub OAuth Token Leak (April 2022) – Stolen OAuth tokens used for unauthorized repository access.
Community Resources:
- GitHub Hardening Guide by iAnonymous3000
- Step Security - Harden-Runner for GitHub Actions
- CIS GitHub Benchmark
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2026-03-23 | 0.5.2 | draft | Expand section 2.4 with Gitsign/Sigstore keyless signing, GitHub verification limitations, and CI signing; expand section 3.10 with composite action transitive dependency auditing, container image digest pinning, poutine/frizbee/octopin tools; expand section 5.2 with Docker Hub OIDC gap, GHCR migration path, PyPI/npm OIDC status, irreducible static secrets | Claude Code (Opus 4.6) |
| 2026-03-23 | 0.5.1 | draft | Add section 1.8 (service account cross-org isolation) from TeamPCP Phase 3 findings; update section 6.6 with atomic credential rotation requirement; expand Security Incidents with full three-phase TeamPCP campaign (CanisterWorm, ICP C2, VS Code extension, org defacement) | Claude Code (Opus 4.6) |
| 2026-03-07 | 0.3.0 | draft | Revamp sections 4-9: OAuth app auditing, GHAS unbundling, push protection delegated bypass, custom secret patterns, OIDC, build provenance, Copilot governance, custom roles, required workflows, security overview dashboard | Claude Code (Opus 4.6) |
| 2026-02-12 | 0.2.0 | draft | Merged enterprise guide, added code pack integration, comprehensive controls | Claude Code (Opus 4.6) |
| 2025-12-13 | 0.1.0 | draft | Initial GitHub hardening guide with supply chain security focus | Claude Code (Opus 4.5) |
Contributing
Found an issue or want to improve this guide?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide
Questions or feedback?
- GitHub Discussions: [Link]
- GitHub Issues: [Link]
Sources:
This guide was compiled from the following authoritative sources: