v0.5.2-draft AI Drafted

GitHub Hardening Guide

DevOps Last updated: 2026-03-23

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

  1. Authentication & Access Controls
  2. Repository Security
  3. GitHub Actions & CI/CD Security
  4. OAuth & Third-Party App Security
  5. Secret Management
  6. Dependency & Supply Chain Security
  7. Modern Platform Features
  8. Monitoring & Audit Logging
  9. 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

  1. Navigate to: Organization Settings -> Authentication security
  2. Under “Two-factor authentication”:
    • Select “Require two-factor authentication for everyone in the [org-name] organization”
  3. Set grace period (recommended: 30 days)
  4. Click “Save”

Step 2: Monitor Compliance

  1. Go to: Organization Settings -> People
  2. Filter by “2FA” status to see non-compliant members
  3. Members without 2FA will be removed from org after grace period

Time to Complete: ~5 minutes + 30-day rollout

Code Implementation

Code Pack: Terraform
hth-github-1.01-enforce-2fa-for-org-members.tf View source on GitHub ↗
resource "github_organization_settings" "security" {
  billing_email                      = var.github_organization
  two_factor_requirement             = true
}
Code Pack: API Script
hth-github-1.01-enforce-2fa-for-org-members.sh View source on GitHub ↗
# 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
hth-github-1.01-enforce-2fa-for-org-members.yml View source on GitHub ↗
detection:
    selection:
        action: 'org.disable_two_factor_requirement'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at

Validation & Testing

  1. Create test user account, add to organization
  2. Verify test user is prompted to enable 2FA
  3. Confirm user cannot access org resources without 2FA setup
  4. 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
hth-github-1.09-monitor-2fa-compliance.sh View source on GitHub ↗
# 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:

  1. Organization Settings -> Authentication security
  2. Uncheck “Require two-factor authentication”
  3. 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

  1. Organization Settings -> Member privileges
  2. Under “Base permissions”:
    • Set to “No permission” (recommended) or “Read”
    • NOT “Write” or “Admin”
  3. Click “Save”

Step 2: Use Teams for Access

  1. Create teams for projects/repos
  2. Grant teams specific repository access
  3. Add members to relevant teams only

Step 3: Configure Additional Member Privileges

  1. Navigate to: Organization Settings -> Member privileges
  2. Configure:
    • Repository creation: Restrict to specific roles
    • Repository forking: Disable for private repos
    • Pages creation: Restrict as needed

Code Implementation

Code Pack: Terraform
hth-github-1.02-restrict-default-repository-permissions.tf View source on GitHub ↗
resource "github_organization_settings" "permissions" {
  billing_email                      = var.github_organization
  default_repository_permission      = "read"
}
Code Pack: API Script
hth-github-1.02-restrict-default-repository-permissions.sh View source on GitHub ↗
# 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
hth-github-1.02-restrict-default-repository-permissions.yml View source on GitHub ↗
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

  1. Navigate to: Enterprise Settings -> Authentication security
  2. Click Enable SAML authentication
  3. Configure SAML settings:
    • Sign on URL
    • Issuer
    • Public certificate
  4. Configure attribute mappings
  5. Test authentication with pilot users before requiring

Step 2: Require SAML SSO

  1. After testing, select Require SAML authentication
  2. Configure recovery codes for break-glass access
  3. Document emergency access procedures
  4. Enable “Require SAML SSO authentication for all members”

Step 3: Configure SCIM Provisioning

  1. Navigate to: Enterprise Settings -> Authentication security -> SCIM configuration
  2. Generate SCIM token
  3. Configure IdP SCIM provisioning:
    • User provisioning
    • Group synchronization
    • Deprovisioning
  4. Test user lifecycle (create, update, deactivate)

Time to Complete: ~1 hour

Code Implementation

Code Pack: API Script
hth-github-1.10-verify-saml-sso-status.sh View source on GitHub ↗
# 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
hth-github-1.03-restrict-repository-creation.tf View source on GitHub ↗
resource "github_organization_settings" "repo_creation" {
  billing_email                           = var.github_organization
  members_can_create_public_repositories  = false
}
Code Pack: API Script
hth-github-1.03-restrict-repository-creation.sh View source on GitHub ↗
# 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
hth-github-1.03-restrict-repository-creation.yml View source on GitHub ↗
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

  1. Navigate to: Enterprise Settings -> People -> Enterprise owners
  2. Limit to 2-3 essential personnel
  3. Ensure each owner has MFA enabled
  4. Document owner responsibilities

Step 2: Configure Organization Owner Policies

  1. Review organization owners across all orgs
  2. Limit to essential personnel per org
  3. Create separate admin accounts for privileged operations
  4. Enforce MFA for all admin accounts

Step 3: Audit Admin Activity

  1. Regular review of admin audit logs
  2. Alert on privilege escalation events
  3. 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
hth-github-1.08-audit-admin-access.sh View source on GitHub ↗
# 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
hth-github-1.04-disable-private-fork-creation.tf View source on GitHub ↗
resource "github_organization_settings" "forking" {
  billing_email                           = var.github_organization
  members_can_fork_private_repositories   = false
}
Code Pack: API Script
hth-github-1.04-disable-private-fork-creation.sh View source on GitHub ↗
# 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
hth-github-1.04-disable-private-fork-creation.yml View source on GitHub ↗
detection:
    selection:
        action: 'org.update_member_repository_forking_permission'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-1.05-require-commit-signoff.tf View source on GitHub ↗
resource "github_organization_settings" "commit_signoff" {
  billing_email                      = var.github_organization
  web_commit_signoff_required        = true
}
Code Pack: API Script
hth-github-1.05-require-commit-signoff.sh View source on GitHub ↗
# 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
hth-github-1.05-require-commit-signoff.yml View source on GitHub ↗
detection:
    selection:
        action: 'org.update_default_repository_permission'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-1.06-restrict-org-member-repository-deletion.tf View source on GitHub ↗
resource "github_organization_settings" "repo_deletion" {
  billing_email                      = var.github_organization
  default_repository_permission      = "read"
  members_can_create_repositories    = false
}
Code Pack: API Script
hth-github-1.06-restrict-org-member-repository-deletion.sh View source on GitHub ↗
# 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
hth-github-1.06-restrict-org-member-repository-deletion.yml View source on GitHub ↗
detection:
    selection:
        action: 'repo.destroy'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at

Validation & Testing

  1. Verify enterprise owner count is 2-3 maximum
  2. Confirm all admin accounts have MFA enabled
  3. Test that non-admin members cannot access admin settings
  4. Verify audit logging captures admin actions

Monitoring & Maintenance

Alert Configuration:

Code Pack: API Script
hth-github-1.11-monitor-admin-privilege-escalation.sh View source on GitHub ↗
# 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

  1. Navigate to: Enterprise Settings -> Authentication security -> IP allow list
  2. Click Enable IP allow list

Step 2: Add Allowed IPs

  1. Click Add IP address or range
  2. Add corporate network IPs/CIDR ranges
  3. Add VPN egress IPs
  4. Add CI/CD runner IPs (if applicable)
  5. Enable for GitHub Apps: Optionally apply to installed GitHub Apps

Step 3: Validate Access

  1. Test access from allowed IPs
  2. Verify blocked access from other IPs
  3. Document emergency procedures if blocked

Code Implementation

Code Pack: API Script
hth-github-1.07-configure-ip-allow-list.sh View source on GitHub ↗
# 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

  1. Access GitHub from an allowed IP (should succeed)
  2. Access GitHub from a non-allowed IP (should fail)
  3. Verify CI/CD pipelines still function with runner IPs allowed
  4. 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

  1. Navigate to: Organization Settings -> Personal access tokens -> Settings
  2. Select the Tokens (classic) tab
  3. Under “Restrict personal access tokens (classic) from accessing your organizations”:
    • Select “Do not allow access via personal access tokens (classic)”
  4. Click Save

Step 2: Require Approval for Fine-Grained PATs

  1. Navigate to: Organization Settings -> Personal access tokens -> Settings
  2. Select the Fine-grained tokens tab
  3. Under “Require approval of fine-grained personal access tokens”:
    • Select “Require administrator approval”
  4. Click Save

Step 3: Set Maximum Token Lifetime

  1. On the same settings page, under “Set maximum lifetimes for personal access tokens”:
    • Set to 90 days (recommended) or per your organization’s policy
  2. Click Save

Step 4: Enterprise-Level Enforcement (Enterprise Cloud)

  1. Navigate to: Enterprise Settings -> Policies -> Personal access tokens
  2. Under Tokens (classic) tab:
    • Select “Restrict access via personal access tokens (classic)”
  3. Under Fine-grained tokens tab:
    • Select “Require approval”
    • Set maximum lifetime policy
  4. Click Save

Time to Complete: ~10 minutes

Code Implementation

Code Pack: API Script
hth-github-1.12-enforce-pat-policies.sh View source on GitHub ↗
# 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
hth-github-1.12-enforce-fine-grained-pat-policies.yml View source on GitHub ↗
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

  1. Attempt to create a classic PAT and access the organization (should fail if restricted)
  2. Create a fine-grained PAT and verify it requires admin approval
  3. Verify PAT expiration is enforced within the maximum lifetime
  4. 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-Mgt service account (GitHub ID 139343333) had write/admin access to both the public aquasecurity organization and the internal aquasec-com organization. After compromising the public org via a pull_request_target exploit, 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 a tpcp-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

  1. Navigate to: Organization Settings -> People
  2. Filter by role to identify non-human accounts (look for naming patterns like *-bot, *-mgt, *-ci, *-automation, *-svc)
  3. For each identified service account, check: User Profile -> Organizations to see which other orgs the account belongs to
  4. Document all service accounts with access to more than one organization

Step 2: Isolate Service Accounts Per Organization

  1. For each cross-org service account, create a separate, dedicated account per organization
  2. Transfer repository access, team memberships, and secrets to the new per-org accounts
  3. Remove the cross-org account from all but one organization
  4. 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

  1. Create a GitHub App for each automation use case
  2. GitHub Apps can be installed on specific repositories within an organization — they cannot inherently access other organizations
  3. Use installation tokens (short-lived, 1-hour expiry) instead of PATs
  4. GitHub Apps provide granular permission scoping that user accounts cannot match

Step 4: Enforce Credential Rotation Atomicity

  1. 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)
  2. The Trivy Phase 2 compromise occurred because credential rotation after Phase 1 was non-atomic — attackers accessed refreshed tokens during the rotation window
  3. 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

  1. All service accounts are inventoried with org membership documented
  2. No service account has admin/write access to more than one organization
  3. Cross-org automation uses GitHub App installations, not shared user accounts
  4. 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).

  1. Navigate to: Repository Settings -> Rules -> Rulesets
  2. Click New ruleset -> New branch ruleset
  3. Configure branch targeting: main, master, release/*
  4. Enable rules (see Section 2.3 for full details)

Option B: Legacy Branch Protection Rules

  1. Navigate to: Repository Settings -> Branches
  2. Under “Branch protection rules”, click “Add branch protection rule”
  3. Branch name pattern: main (or master, production)
  4. 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)
  5. Click “Create”

Repeat for all critical branches.

Time to Complete: ~10 minutes per repository

Code Implementation

Code Pack: SDK Script
hth-github-3.10-bulk-branch-protection.py View source on GitHub ↗
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
hth-github-3.01-enable-branch-protection-on-default-branch.tf View source on GitHub ↗
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
hth-github-3.01-enable-branch-protection-on-default-branch.sh View source on GitHub ↗
# 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
hth-github-3.01-enable-branch-protection-on-default-branch.yml View source on GitHub ↗
detection:
    selection:
        action: 'protected_branch.destroy'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-3.06-require-codeowners-file.tf View source on GitHub ↗
# 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
hth-github-3.06-require-codeowners-file.sh View source on GitHub ↗
# 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
hth-github-3.06-require-codeowners-file.yml View source on GitHub ↗
detection:
    selection:
        action: 'protected_branch.update'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-3.07-require-signed-commits.tf View source on GitHub ↗
resource "github_branch_protection" "main_signed" {
  repository_id          = var.repository_id
  pattern                = "main"
  require_signed_commits = true
}
Code Pack: API Script
hth-github-3.07-require-signed-commits.sh View source on GitHub ↗
# 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
hth-github-3.07-require-signed-commits.yml View source on GitHub ↗
detection:
    selection:
        action: 'protected_branch.update'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at

Validation & Testing

  1. Attempt to push directly to protected branch (should fail)
  2. Create PR without required status checks (should block merge)
  3. Create PR without required approvals (should block merge)
  4. Verify admin cannot bypass (if enforce_admins enabled)

Monitoring & Maintenance

Alert on protection changes:

Code Pack: API Script
hth-github-2.06-alert-branch-protection-changes.sh View source on GitHub ↗
# 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
hth-github-2.06-alert-branch-protection-changes.yml View source on GitHub ↗
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

  1. Organization Settings -> Code security and analysis
  2. 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)
  3. 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)

  1. Navigate to: Repository Settings -> Code security and analysis
  2. Enable same features
  3. 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)

  1. Navigate to: Organization Settings -> Code security -> Secret scanning
  2. Add custom patterns for:
    • Internal API keys
    • Database connection strings
    • Custom tokens
  3. Enable “Include in push protection” for each custom pattern (GA since August 2025)

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-github-2.01-enable-secret-scanning.tf View source on GitHub ↗
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
hth-github-2.01-enable-secret-scanning.sh View source on GitHub ↗
# 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
hth-github-2.01-enable-secret-scanning.yml View source on GitHub ↗
detection:
    selection:
        action: 'repository_secret_scanning.disable'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-2.02-enable-dependabot-security-updates.tf View source on GitHub ↗
resource "github_repository" "how_to_harden_dependabot" {
  name               = var.repository_name
  vulnerability_alerts = true
}
Code Pack: API Script
hth-github-2.02-enable-dependabot-security-updates.sh View source on GitHub ↗
# 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
hth-github-2.02-enable-dependabot-security-updates.yml View source on GitHub ↗
detection:
    selection:
        action: 'repository_vulnerability_alert.dismiss'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-2.03-enable-secret-scanning-non-provider-patterns.tf View source on GitHub ↗
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
hth-github-2.03-enable-secret-scanning-non-provider-patterns.sh View source on GitHub ↗
# 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
hth-github-2.03-enable-secret-scanning-non-provider-patterns.yml View source on GitHub ↗
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
hth-github-2.04-enable-secret-scanning-validity-checks.tf View source on GitHub ↗
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
hth-github-2.04-enable-secret-scanning-validity-checks.sh View source on GitHub ↗
# 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
hth-github-2.04-enable-secret-scanning-validity-checks.yml View source on GitHub ↗
detection:
    selection:
        action: 'repository_secret_scanning.disable'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-3.05-enable-code-scanning-default-setup.tf View source on GitHub ↗
resource "github_repository" "how_to_harden_code_scanning" {
  name = var.repository_name

  security_and_analysis {
    advanced_security {
      status = "enabled"
    }
  }
}
Code Pack: API Script
hth-github-3.05-enable-code-scanning-default-setup.sh View source on GitHub ↗
# 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
hth-github-3.05-codeql-advanced-workflow.yml View source on GitHub ↗
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
hth-github-3.05-enable-code-scanning-default-setup.yml View source on GitHub ↗
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 default
  • security-and-quality – Full security plus code quality queries
  • Custom query packs for organization-specific patterns

Monitoring Alerts

Code Pack: API Script
hth-github-2.07-monitor-security-alerts.sh View source on GitHub ↗
# 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

  1. Navigate to: Organization Settings -> Repository -> Rulesets
  2. Click New ruleset -> New branch ruleset

Step 2: Configure Ruleset

  1. Name: Production Branch Protection
  2. Enforcement status: Active
  3. Target repositories: All or selected repositories
  4. Branch targeting: Include main, master, release/*

Step 3: Configure Rules

  1. 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
  2. Configure bypass list (limit to emergency access only)
  3. Tag Protection via Repository Rules:
    • Legacy tag protection rules are deprecated – use rulesets instead
    • In the same ruleset, add a Tag ruleset targeting v* and release-* patterns
    • Enable: Restrict creations, Restrict deletions, Block force pushes
    • This prevents unauthorized release tagging and protects release integrity

Code Implementation

Code Pack: Terraform
hth-github-2.05-configure-org-rulesets.tf View source on GitHub ↗
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
hth-github-2.05-configure-org-rulesets.sh View source on GitHub ↗
# 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

  1. Verify ruleset is active and applies to target branches
  2. Attempt direct push to protected branch (should fail)
  3. Verify bypass list is limited to emergency accounts only
  4. 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

  1. Navigate to: User Settings -> SSH and GPG keys
  2. Enable Flag unsigned commits as unverified

Step 2: Require Signed Commits in Branch Protection

  1. Navigate to: Repository Settings -> Branches -> Edit rule
  2. Enable Require signed commits

Step 3: Enforce via Organization Ruleset (recommended)

  1. Navigate to: Organization Settings -> Repository -> Rulesets
  2. Edit production ruleset
  3. 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:

  1. git config --local gpg.x509.program gitsign
  2. git config --local gpg.format x509
  3. git config --local commit.gpgsign true
  4. Signing a commit opens a browser for OIDC authentication; use gitsign-credential-cache to 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
hth-github-2.08-configure-commit-signing.sh View source on GitHub ↗
# 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
hth-github-3.07-require-signed-commits.tf View source on GitHub ↗
resource "github_branch_protection" "main_signed" {
  repository_id          = var.repository_id
  pattern                = "main"
  require_signed_commits = true
}
Code Pack: API Script
hth-github-3.07-require-signed-commits.sh View source on GitHub ↗
# 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
hth-github-3.07-require-signed-commits.yml View source on GitHub ↗
detection:
    selection:
        action: 'protected_branch.update'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at

Validation & Testing

  1. Create an unsigned commit and attempt to push (should fail if required)
  2. Create a signed commit and verify it shows as “Verified” in GitHub UI
  3. Verify vigilant mode flags unsigned commits from other contributors
  4. If using Gitsign: verify signature with gitsign verify and 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

  1. Navigate to: Organization Settings -> Repository -> Rulesets
  2. Click New ruleset -> New push ruleset

Step 2: Configure Targets

  1. Name: File Protection Push Rules
  2. Enforcement status: Active
  3. Target repositories: All repositories or selected
  4. Configure bypass list (limit to org admins only)

Step 3: Add Push Rules

  1. Restrict file extensions: Add .exe, .dll, .so, .dylib, .bin, .jar, .war, .class
  2. Restrict file size: Set maximum to 10 MB (adjust per your needs)
  3. Restrict file paths: Add .github/workflows/** to prevent unauthorized workflow changes

Time to Complete: ~10 minutes

Code Implementation

Code Pack: API Script
hth-github-2.09-enable-private-vulnerability-reporting.sh View source on GitHub ↗
# 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
hth-github-2.09-enable-private-vulnerability-reporting.yml View source on GitHub ↗
detection:
    selection:
        action: 'repository_vulnerability_alert.disable'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at

Validation & Testing

  1. Attempt to push an .exe file (should be blocked)
  2. Attempt to push a file larger than the size limit (should be blocked)
  3. Attempt to push workflow changes from a fork (should be blocked if path-restricted)
  4. 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)

  1. Navigate to: Organization Settings -> Code security and analysis
  2. Under “Secret scanning”:
    • Enable Secret scanning for all repositories
    • Enable Push protection for all repositories

Step 2: Configure Delegated Bypass

  1. Navigate to: Organization Settings -> Code security -> Global settings
  2. Under “Push protection”:
    • Select “Require approval to bypass push protection”
    • Add your security team as designated reviewers
  3. Click Save

Step 3: Add Custom Patterns to Push Protection

  1. Navigate to: Organization Settings -> Code security -> Secret scanning
  2. Click New pattern
  3. Define pattern:
    • Name: e.g., Internal API Key
    • Secret format: regex pattern matching your internal key format
    • Enable “Include in push protection”
  4. Test pattern with sample data
  5. Click Publish pattern

Time to Complete: ~15 minutes

Code Implementation

Code Pack: API Script
hth-github-2.10-enable-delegated-bypass.sh View source on GitHub ↗
# 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
hth-github-2.10-configure-secret-scanning-delegated-bypass.yml View source on GitHub ↗
detection:
    selection:
        action: 'secret_scanning_push_protection.bypass'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
    - push_protection_bypass_reason

Validation & Testing

  1. Attempt to push a commit containing a known secret (should be blocked)
  2. Attempt to bypass push protection (should require reviewer approval if delegated bypass enabled)
  3. Verify custom patterns detect organization-specific secrets
  4. 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/checkout vs actions/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)

  1. Navigate to: Enterprise Settings -> Policies -> Actions
  2. Configure allowed actions:
    • Allow enterprise and select non-enterprise actions (recommended)
    • Specify allowed patterns: github/*, actions/*
  3. Restrict to verified creators if possible

Step 2: Set Organization Action Policy

  1. Organization Settings -> Actions -> General
  2. Under “Actions permissions”:
    • Select “Allow [org-name], and select non-[org-name], actions and reusable workflows”
  3. 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)
  4. Click “Save”
Code Pack: CLI Script
hth-github-3.12-actions-allowlist.yml View source on GitHub ↗
# 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
hth-github-3.02-restrict-actions-to-verified-creators.tf View source on GitHub ↗
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
hth-github-3.02-restrict-actions-to-verified-creators.sh View source on GitHub ↗
# 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
hth-github-3.02-restrict-actions-to-verified-creators.yml View source on GitHub ↗
detection:
    selection:
        action: 'repo.actions_enabled'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-3.03-pin-actions-to-sha.tf View source on GitHub ↗
# 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
hth-github-3.03-pin-actions-to-sha.sh View source on GitHub ↗
# 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
hth-github-3.03-pin-actions-to-sha.yml View source on GitHub ↗
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 actions
  • github/* - GitHub-maintained

Tier 2 - Verified Vendors:

  • aws-actions/* - AWS official
  • azure/* - Microsoft Azure
  • google-github-actions/* - Google Cloud
  • hashicorp/* - 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
hth-github-3.13-pin-actions-examples.yml View source on GitHub ↗
# 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
hth-github-3.18-pin-actions-automation.sh View source on GitHub ↗
# Use https://github.com/mheap/pin-github-action
npx pin-github-action .github/workflows/*.yml

Automated SHA updates with Dependabot:

Code Pack: CLI Script
hth-github-3.11-dependabot-actions-config.yml View source on GitHub ↗
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Monitoring & Maintenance

Alert on unapproved Action usage:

Code Pack: API Script
hth-github-3.08-audit-workflow-actions.sh View source on GitHub ↗
# 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

  1. Organization Settings -> Actions -> General
  2. Under “Workflow permissions”:
    • Select “Read repository contents and packages permissions” (read-only)
    • Do NOT check “Allow GitHub Actions to create and approve pull requests”
  3. Click “Save”

Step 2: Configure at Repository Level

  1. Navigate to: Repository Settings -> Actions -> General
  2. Set Workflow permissions to Read repository contents
  3. 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
hth-github-3.04-restrict-org-actions-permissions.tf View source on GitHub ↗
# 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
hth-github-3.04-restrict-org-actions-permissions.sh View source on GitHub ↗
# 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
hth-github-3.04-workflow-permissions-template.yml View source on GitHub ↗
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
hth-github-3.04-restrict-org-actions-permissions.yml View source on GitHub ↗
detection:
    selection:
        action: 'org.actions_enabled'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at

Common Permission Combinations

Code Pack: CLI Script
hth-github-3.14-workflow-permission-examples.yml View source on GitHub ↗
# .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
hth-github-3.15-audit-workflow-permissions.sh View source on GitHub ↗
# 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

  1. Repository Settings -> Actions -> General
  2. Under “Fork pull request workflows from outside collaborators”:
    • Select “Require approval for first-time contributors” (L2)
    • Or “Require approval for all outside collaborators” (L3)
  3. Save

Code Implementation

Code Pack: API Script
hth-github-3.16-workflow-approval-contributors.sh View source on GitHub ↗
# 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)

  1. Use ephemeral runners (new VM per job) – this is the single most impactful security measure
  2. Configure runners with --ephemeral flag so they deregister after one job
  3. Never run on controller/sensitive systems
  4. Use dedicated runner network segment with firewall rules
  5. 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

  1. Navigate to: Organization Settings -> Actions -> Runner groups
  2. Create runner groups for different trust levels:
    • production-runners – only for deployment workflows from trusted repos
    • general-runners – for CI/CD from internal repositories
    • public-runners – isolated runners with no secrets for public fork builds
  3. Restrict which repositories can use each group

Step 3: Configure Runner Labels

  1. Use labels to route jobs to appropriate runners
  2. Production deployments: dedicated secure runners
  3. Public fork builds: isolated runners with no secrets access

Code Implementation

Code Pack: API Script
hth-github-3.09-configure-runner-groups.sh View source on GitHub ↗
# 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
hth-github-3.17-ephemeral-runner-config.yml View source on GitHub ↗
# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: [self-hosted, production, ephemeral]
    environment: production
    steps:
      - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
      - run: ./deploy.sh

Validation & Testing

  1. Verify ephemeral runners are destroyed after each job
  2. Test that public repos cannot access production runner groups
  3. Verify network segmentation between runner groups
  4. 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

  1. Edit your build workflow file (e.g., .github/workflows/build.yml)
  2. Add required permissions: id-token: write, contents: read, attestations: write
  3. Add the actions/attest-build-provenance step after your build step
  4. See the workflow template in the Code Pack below

Step 2: Verify Attestations

  1. 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

Step 3: Achieve SLSA Build Level 3 (optional)

  1. Move build logic into a reusable workflow (.github/workflows/build-reusable.yml)
  2. Call the reusable workflow from your main workflow
  3. 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
hth-github-3.19-enable-code-security-configurations.sh View source on GitHub ↗
# 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
hth-github-3.19-artifact-attestation-workflow.yml View source on GitHub ↗
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
hth-github-3.19-enable-code-security-configurations.yml View source on GitHub ↗
detection:
    selection:
        action: 'code_security_configuration.detach'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: API Script
hth-github-3.20-verify-attestations.sh View source on GitHub ↗
# 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

  1. Run the attestation workflow and confirm it succeeds
  2. Verify the attestation using gh attestation verify
  3. Confirm attestation metadata includes correct repository and workflow references
  4. 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/BRANCH format
  • Any workflow in that repo/branch can assume the cloud role – not just your deploy workflow
  • Customizing claims to include job_workflow_ref restricts 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

  1. Navigate to: Repository Settings -> Environments -> Select environment
  2. Under “OpenID Connect”:
    • Click “Use custom template” (if available via API)
  3. Alternatively, use the API to set custom claims (see Code Pack below)

Step 2: Configure at Organization Level

  1. Use the REST API to set organization-wide OIDC claim defaults
  2. Include repo, context, and job_workflow_ref in the subject claim
  3. See the Code Pack below for API implementation

Step 3: Update Cloud Provider Trust Policies

  1. Update AWS IAM, GCP Workload Identity, or Azure trust policies to match new claim format
  2. Test with a non-production environment first
  3. Gradually roll out to production

Time to Complete: ~20 minutes + cloud provider policy updates

Code Implementation

Code Pack: API Script
hth-github-3.21-configure-oidc-claims.sh View source on GitHub ↗
# 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

  1. Verify OIDC claims are customized using the API
  2. Test that only the intended workflow can assume the cloud role
  3. Verify that a different workflow in the same repo is denied access
  4. Confirm check_run_id appears 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

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/*/mem and exfiltrated cloud credentials to a typosquat domain. Harden-Runner detected the anomalous C2 callout to scan.aquasecurtiy.org within hours. zizmor would have flagged the pre-existing pull_request_target vulnerability (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 via npx 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

  1. Create a workflow file .github/workflows/actions-security.yml
  2. Add zizmor and actionlint as steps (see Code Pack below)
  3. Configure SARIF upload for GitHub Code Scanning integration

Step 2: Run Configuration Hardening

  1. Visit app.stepsecurity.io and connect your repository
  2. Review the generated PR that pins actions and adds harden-runner
  3. Alternatively, run npx pin-github-action locally on your workflow files

Step 3: Enable Runtime Monitoring

  1. Add the step-security/harden-runner@v2 step as the first step in each job
  2. Start in audit-mode to observe before switching to block mode

Step 4: Deploy Organization Governance

  1. Install the Allstar GitHub App from the GitHub Marketplace
  2. Configure policies in an .allstar repository
  3. Schedule monthly Legitify scans via CI or run manually

Time to Complete: ~30 minutes for initial setup

Validation & Testing

  1. zizmor and actionlint run on PRs and report findings
  2. All actions in workflows are pinned to commit SHAs
  3. harden-runner is present as the first step in critical workflows
  4. OpenSSF Scorecard produces a score for the repository
  5. 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_target workflows across 7 Aqua Security repositories including aquasecurity/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_target vulnerability 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

  1. Search your .github/workflows/ directory for pull_request_target
  2. For each workflow found, verify it does NOT checkout PR head code:
    • Check for actions/checkout with ref: ${{ github.event.pull_request.head.sha }}
    • Check for actions/checkout with ref: ${{ github.event.pull_request.head.ref }}
    • Either of these patterns is UNSAFE when combined with pull_request_target
  3. 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

  1. Split-workflow pattern (recommended): Run untrusted builds in pull_request (no secrets), perform trusted operations (labeling, commenting, deploying) in a separate pull_request_target workflow that never checks out PR code
  2. Artifact handoff pattern: Build artifacts in pull_request, upload them, then download and consume in pull_request_target
  3. Metadata-only pattern: If pull_request_target only needs PR metadata (title, labels, author), use actions/github-script to read the API instead of checking out code

Step 3: Prevent Expression Injection

  1. Never use ${{ github.event.pull_request.* }} directly in run: blocks
  2. Pass untrusted values through environment variables instead
  3. Use actions/github-script for operations that need PR content

Time to Complete: ~30 minutes to audit + refactor workflows

Code Implementation

Code Pack: CLI Script
hth-github-3.22-secure-pull-request-target.yml View source on GitHub ↗
# 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

  1. No pull_request_target workflow checks out PR head code
  2. No run: blocks directly interpolate ${{ github.event.pull_request.* }}
  3. zizmor audit passes with no pull_request_target findings
  4. 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/*/mem from the Runner.Worker process to harvest cloud credentials, SSH keys, and tokens, (3) exfiltrated stolen secrets to scan.aquasecurtiy.org (typosquat of aquasecurity.org). The payload also attempted to create a repo named tpcp-docs in 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

  1. Add step-security/harden-runner as the first step in every workflow job
  2. Start with egress-policy: audit to observe legitimate network connections
  3. After 1-2 weeks, review the StepSecurity dashboard for the allow-list
  4. Switch to egress-policy: block with an explicit allowed-endpoints list
  5. Only allow endpoints your workflow actually needs (GitHub APIs, package registries)

Step 2: Harden Self-Hosted Runner Containers (if applicable)

  1. Run runner containers as non-root with runAsUser: 1000
  2. Set readOnlyRootFilesystem: true with writable tmpfs for _work and /tmp
  3. Drop ALL Linux capabilities: capabilities: { drop: ["ALL"] }
  4. Apply a seccomp profile that blocks ptrace, process_vm_readv, and process_vm_writev syscalls
  5. Set allowPrivilegeEscalation: false

Step 3: Enable Harden-Runner Advanced Policies

  1. 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.
  2. Lockdown Mode: Blocks job execution when suspicious process events are detected, such as a process reading Runner.Worker memory via /proc/<pid>/mem. In the Trivy v0.69.4 attack, Harden-Runner detected python3 (PID 2538) reading /proc/2167/mem to extract secrets.
  3. 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)

  1. Create a Kubernetes NetworkPolicy restricting runner pod egress
  2. Allow only DNS (port 53) and HTTPS (port 443) to known endpoints
  3. Block all other outbound traffic including SSH, HTTP, and non-standard ports
  4. Monitor blocked connections for anomaly detection

Time to Complete: ~45 minutes (Harden-Runner); ~2 hours (K8s runner hardening)

Code Implementation

Code Pack: CLI Script
hth-github-3.23-runner-process-network-isolation.yml View source on GitHub ↗
# 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

  1. Harden-Runner is the first step in all critical workflow jobs
  2. Egress policy is set to block (not audit) in production workflows
  3. Allowed endpoints list contains only necessary domains
  4. Self-hosted runner containers run as non-root with dropped capabilities
  5. Seccomp profile blocks ptrace and /proc memory access syscalls
  6. Test: a workflow step attempting curl https://evil.example.com is 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

  1. Run frizbee ghactions .github/workflows/*.yml or npx pin-github-action .github/workflows/*.yml to convert tag references to SHA pins
  2. Add version comments after each SHA for readability: @abc123 # v4.1.1
  3. Pin container images in container: and services: directives by digest (e.g., node:18@sha256:a1b2c3...) — use frizbee containers .github/workflows/*.yml to automate this
  4. Configure Dependabot or Renovate to automatically propose SHA and digest updates when new versions release
  5. 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

  1. 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
  2. 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
  3. Use octopin to list all transitive action dependencies including those inside composite actions: octopin list --transitive .github/workflows/
  4. For actions with unpinned internal dependencies: fork and pin internally, file an upstream PR, or use gh-actions-lockfile to generate and verify a lockfile with SHA-256 integrity hashes for the full transitive tree
  5. 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
  6. Docker-based actions that reference docker://image:tag in their action.yml are “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

  1. Create or update .github/dependabot.yml in your repository
  2. Add both github-actions and docker ecosystem entries with weekly update schedule
  3. Dependabot will propose PRs when pinned SHAs and image digests become outdated

Step 5: Monitor for Tag Poisoning and Imposter Commits

  1. Imposter commits: Tags pointing to commits not reachable from any branch — use Chainguard’s clank tool (chainguard-dev/clank) to automatically detect imposter commits in workflow files
  2. Missing signatures: Tags that previously had GPG signatures suddenly lack them
  3. Timestamp anomalies: Tag commits with dates significantly different from surrounding commits
  4. Unexpected tag updates: GitHub audit log entries for git.push events on tag refs
  5. 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)
  6. Run the SHA verification audit script (see Code Pack) on a schedule

Step 6: Sign Action Release Tags

  1. Use Sigstore Gitsign for keyless, transparent signing of Git tags and commits in action repositories you maintain (see Section 2.4 for Gitsign setup)
  2. Per-repository signing identities ensure consumers can verify tag authenticity
  3. Treat published actions as release artifacts — sign them with the same rigor as container images or packages

Step 7: Deploy Runtime Detection

  1. Add StepSecurity Harden-Runner to detect anomalous network egress from action steps
  2. 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
hth-github-3.24-detect-tag-poisoning.sh View source on GitHub ↗
# 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

  1. All action references use full 40-character commit SHAs
  2. All container: and services: images pinned by digest
  3. Composite action transitive dependencies audited with poutine or octopin
  4. Dependabot or Renovate is configured for github-actions and docker ecosystems
  5. SHA verification audit runs on a schedule (weekly minimum)
  6. No SHA mismatches detected between pinned values and tag targets
  7. clank scan confirms no imposter commits (SHAs reachable from parent branches only)
  8. 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

  1. Organization Settings -> Third-party access -> OAuth application policy
  2. Click “Setup application access restrictions”
  3. Review pending requests and approve only necessary apps
  4. This ensures unapproved OAuth apps cannot access organization data

Step 2: Audit Installed Apps

  1. Organization Settings -> GitHub Apps (for GitHub Apps)
  2. Organization Settings -> OAuth Apps (for OAuth apps)
  3. For each app, review:
    • Last used date
    • Granted permissions and scopes
    • Repository access (all repos vs. selected)
  4. 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

  1. Organization Settings -> Third-party access -> Access requests
  2. Configure whether outside collaborators can request app access
  3. Set notification preferences for pending requests

Time to Complete: ~30 minutes for initial audit

Code Implementation

Code Pack: API Script
hth-github-4.04-audit-oauth-apps.sh View source on GitHub ↗
# 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
hth-github-4.04-audit-oauth-apps.yml View source on GitHub ↗
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
hth-github-4.06-audit-oauth-apps.py View source on GitHub ↗
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
hth-github-4.03-audit-deploy-keys.sh View source on GitHub ↗
# 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
hth-github-4.03-audit-deploy-keys.yml View source on GitHub ↗
detection:
    selection:
        action: 'deploy_key.create'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
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

  1. Navigate to: Organization Settings -> GitHub Apps
  2. For each installed app, click “Configure”
  3. 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

  1. For each app, change from “All repositories” to “Only select repositories”
  2. Select only the repositories the app actually needs
  3. Click “Save”

Step 3: Flag Excessive Permissions

  • Apps should not have administration: write unless they manage repo settings
  • Apps should not have organization_administration: write unless 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
hth-github-4.07-enable-artifact-attestations.sh View source on GitHub ↗
# 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
hth-github-4.07-enable-artifact-attestations.yml View source on GitHub ↗
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
hth-github-4.07-enable-artifact-attestations.yml View source on GitHub ↗
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 – repo grants 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

  1. Navigate to: Organization Settings -> Personal access tokens
  2. 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”
  3. Under “Personal access tokens (classic)”:
    • Set to “Restrict access via personal access tokens (classic)”

Step 2: Review Pending Requests

  1. Organization Settings -> Personal access tokens -> Pending requests
  2. Review each request: owner, repositories, permissions, expiration
  3. Approve or deny based on least-privilege principle

Step 3: Audit Active Tokens

  1. Organization Settings -> Personal access tokens -> Active tokens
  2. Review all active fine-grained PATs
  3. 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
hth-github-4.08-enforce-fine-grained-pats.sh View source on GitHub ↗
# 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
hth-github-4.08-configure-dependabot-grouped-security-updates.yml View source on GitHub ↗
# .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

  1. Navigate to: Organization Settings -> Secrets and variables -> Actions
  2. Create secrets at organization level for shared credentials
  3. Restrict repository access to minimum necessary

Step 2: Store Repository Secrets

  1. Repository Settings -> Secrets and variables -> Actions
  2. Click “New repository secret”
  3. Name: PROD_API_KEY (use descriptive names)
  4. Value: [paste secret]
  5. Click “Add secret”

Step 3: Create Environment with Protection

  1. Repository Settings -> Environments
  2. Click “New environment”, name it production
  3. Configure protection rules:
    • Required reviewers (add team/users who must approve)
    • Wait timer (optional: delay before deployment)
    • Deployment branches (only main can deploy to production)
  4. Add environment-specific secrets to this environment (most secure)

Step 4: Create Staging Environment

  1. Create staging environment with lighter restrictions
  2. Add staging-specific secrets
  3. Allow deployment from main and develop branches

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-github-5.05-protect-deployment-environments.tf View source on GitHub ↗
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
hth-github-5.05-protect-deployment-environments.sh View source on GitHub ↗
# 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
hth-github-5.05-deployment-secrets-workflow.yml View source on GitHub ↗
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
hth-github-5.05-protect-deployment-environments.yml View source on GitHub ↗
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):

  1. Environment secrets – Only accessible during deployment to that environment
  2. Repository secrets – Accessible to all workflows in the repository
  3. 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
hth-github-5.09-monitor-secret-access.sh View source on GitHub ↗
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
hth-github-5.09-monitor-secret-access.yml View source on GitHub ↗
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-publish with id-token: write permission.
  • 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 (--provenance flag) but still requires a static NPM_TOKEN for 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

  1. In AWS IAM Console, create OIDC provider:
    • Provider URL: https://token.actions.githubusercontent.com
    • Audience: sts.amazonaws.com

Step 2: Create IAM Role with Trust Policy

  1. Create IAM role with trust policy for token.actions.githubusercontent.com
  2. Restrict the sub claim to your repository and branch
  3. For maximum security, include job_workflow_ref in the condition to restrict to specific deployment workflows

Step 3: Eliminate Docker Hub Static Credentials

  1. 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)
  2. GHCR uses GITHUB_TOKEN — zero static secrets to manage
  3. 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
hth-github-5.06-configure-oidc.sh View source on GitHub ↗
# 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
hth-github-5.06-oidc-deploy-workflow.yml View source on GitHub ↗
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
hth-github-5.06-configure-oidc.yml View source on GitHub ↗
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

  1. Organization Settings -> Code security -> Configurations
  2. Edit your security configuration (or create a new one)
  3. Under Secret scanning, enable Push protection
  4. Apply to all repositories

Step 2: Configure Delegated Bypass

  1. Organization Settings -> Code security -> Configurations
  2. Under push protection settings, set bypass mode to “Require bypass request”
  3. Designate bypass reviewers (security team or specific users)
  4. Set notification preferences for bypass requests

Step 3: Monitor Bypass Requests

  1. Organization Settings -> Code security -> Secret scanning
  2. Review pending bypass requests
  3. Approve or deny based on the secret type and context

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-github-5.10-configure-repository-custom-properties.tf View source on GitHub ↗
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
hth-github-5.10-configure-repository-custom-properties.sh View source on GitHub ↗
# 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
hth-github-5.10-configure-repository-custom-properties.yml View source on GitHub ↗
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

  1. Organization Settings -> Code security -> Secret scanning
  2. Click “New pattern”
  3. 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
  4. Click “Save and dry run” to test against existing repositories

Step 2: Enable in Push Protection

  1. After validating the pattern, edit it
  2. Enable “Include in push protection” (GA August 2025)
  3. This blocks commits containing matches for the custom pattern

Time to Complete: ~15 minutes per pattern

Code Implementation

Code Pack: API Script
hth-github-5.11-define-custom-secret-patterns.sh View source on GitHub ↗
# 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

  1. Repository Settings -> Code security and analysis
  2. Enable Dependency graph (should already be enabled from Section 2.2)

Step 2: Add Dependency Review Action

Code Pack: CLI Script
hth-github-4.05-dependency-review-workflow.yml View source on GitHub ↗
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
hth-github-6.01-manual-dependency-review.sh View source on GitHub ↗
# 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
hth-github-4.01-enable-vulnerability-alerts.tf View source on GitHub ↗
resource "github_repository" "how_to_harden_vuln_alerts" {
  name               = var.repository_name
  vulnerability_alerts = true
}
Code Pack: API Script
hth-github-4.01-enable-vulnerability-alerts.sh View source on GitHub ↗
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
hth-github-4.01-enable-vulnerability-alerts.yml View source on GitHub ↗
detection:
    selection:
        action: 'repository_vulnerability_alerts.disable'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: API Script
hth-github-4.02-review-open-dependabot-alerts.sh View source on GitHub ↗
# 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
hth-github-4.02-review-open-dependabot-alerts.yml View source on GitHub ↗
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

  1. Navigate to repository Insights -> Dependency graph
  2. Review all dependencies for version pinning status

Step 2: Enable Dependabot for Automated Pin Updates

  1. Navigate to repository Settings -> Code security and analysis
  2. Enable Dependabot version updates

Code Implementation

Code Pack: CLI Script
hth-github-6.02-pin-dependencies-by-ecosystem.sh View source on GitHub ↗
# 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
hth-github-5.01-disable-wiki-on-non-documentation-repos.tf View source on GitHub ↗
resource "github_repository" "how_to_harden_wiki" {
  name     = var.repository_name
  has_wiki = false
}
Code Pack: API Script
hth-github-5.01-disable-wiki-on-non-documentation-repos.sh View source on GitHub ↗
# 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
hth-github-5.01-disable-wiki-on-non-documentation-repos.yml View source on GitHub ↗
detection:
    selection:
        action: 'repo.update'
    condition: selection
fields:
    - actor
    - action
    - org
    - repo
    - created_at
Code Pack: Terraform
hth-github-5.02-enable-delete-branch-on-merge.tf View source on GitHub ↗
resource "github_repository" "how_to_harden" {
  name                   = var.repository_name
  delete_branch_on_merge = true
}
Code Pack: API Script
hth-github-5.02-enable-delete-branch-on-merge.sh View source on GitHub ↗
# 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
hth-github-5.02-enable-delete-branch-on-merge.yml View source on GitHub ↗
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

  1. In your repository, create .github/dependabot.yml
  2. Define package ecosystems to monitor (npm, pip, GitHub Actions, etc.)
  3. Configure grouped updates with groups: section

Step 2: Configure Groups

  • Group by dependency type (production vs development)
  • Group by update type (minor and patch together, major separately)
  • Use patterns to group related packages (e.g., all @aws-sdk/* packages)

Step 3: Set Limits

  • Set open-pull-requests-limit to 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
hth-github-6.03-dependabot-grouped-updates.yml View source on GitHub ↗
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

  1. Repository Settings -> Code security and analysis
  2. Enable Artifact attestations (if not already enabled)
  3. For public repos, attestations use the public Sigstore instance
  4. For private repos, attestations use GitHub’s private Sigstore instance (requires Enterprise Cloud)

Step 2: Add Provenance to CI/CD

  • Add actions/attest-build-provenance to your release workflow
  • For npm packages, add --provenance flag to npm publish
  • Ensure workflow has id-token: write and attestations: write permissions

Time to Complete: ~15 minutes

Code Implementation

npm Provenance Publishing:

Code Pack: CLI Script
hth-github-6.04-npm-provenance-workflow.yml View source on GitHub ↗
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
hth-github-6.05-attest-build-provenance.yml View source on GitHub ↗
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

  1. Create a .github/workflows/dependency-review.yml in a central repository (e.g., .github repo)
  2. Configure with your organization’s severity threshold and license policy

Step 2: Create Organization Ruleset

  1. Organization Settings -> Rules -> Rulesets
  2. Click “New ruleset” -> “New branch ruleset”
  3. Set target branches: main, master
  4. Set target repositories: All repositories (or select specific ones)
  5. Under Rules, add “Require workflows to pass before merging”
  6. Select the dependency-review workflow from your central repository
  7. Set enforcement to Active

Time to Complete: ~15 minutes

Code Implementation

Code Pack: API Script
hth-github-6.06-enforce-dependency-review.sh View source on GitHub ↗
# 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 create tpcp-docs repos in victim GitHub accounts.

ClickOps Implementation

Step 1: Immediate Triage (First 30 Minutes)

  1. Identify the compromised action name, affected versions/tags, and the advisory source
  2. Search all organization repositories for references to the compromised action
  3. Determine which workflows ran the compromised version and when
  4. Assess the secrets, OIDC roles, and artifact registries accessible to those workflows

Step 2: Containment (First 2 Hours)

  1. Pin the affected action to a known-good SHA in all repositories, or disable affected workflows entirely
  2. Block the compromised action version at the organization level (Actions allow-list)
  3. If using Harden-Runner, check the StepSecurity dashboard for anomalous egress from recent runs
  4. Disable any OIDC trust relationships that could be exploited with stolen tokens

Step 3: Credential Rotation (First 4 Hours)

  1. 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.
  2. Rotate ALL organization-level secrets, not just those “used by” the compromised action
  3. Rotate ALL repository-level secrets in affected repositories
  4. Rotate ALL environment secrets accessible to affected workflows
  5. Revoke and regenerate any OIDC-based cloud role sessions (AWS STS, Azure, GCP)
  6. Rotate GitHub PATs, deploy keys, and app installation tokens that may have been exposed
  7. Rotate any package registry tokens (npm, PyPI, Docker Hub) accessible to workflows
  8. 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)

  1. 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
  2. Check package managers (Homebrew, apt, yum, Chocolatey) for compromised tool versions
  3. 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.5 and 0.69.6 were pushed to Docker Hub with no GitHub release, and 0.69.6 hijacked the latest tag). See the Docker Hub Hardening Guide for detection scripts
  4. Check Helm chart repositories for version bumps referencing compromised releases
  5. 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

  1. Review workflow run logs for the compromised period — look for base64-encoded payloads, unexpected curl/wget commands, or /proc access patterns
  2. Check GitHub audit logs for suspicious API calls during the compromise window
  3. Search for attacker-created repositories (e.g., tpcp-docs for TeamPCP attacks)
  4. Check artifact registries for builds produced during the compromise — these may contain backdoored code
  5. Review network logs for connections to known malicious domains (C2: scan.aquasecurtiy.org resolving to 45.148.10.212)
  6. Use incident-specific scanning tools when available (e.g., trivy-compromise-scanner which checks workflow runs against known-compromised commit SHAs)

Step 6: Anticipate Anti-Response TTPs

  1. 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
  2. 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
  3. 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

  1. Verify all secrets have been rotated and test that systems function with new credentials
  2. Re-enable workflows with the action pinned to a verified-safe SHA
  3. If your organization publishes packages or actions consumed by others, notify downstream users that builds during the compromise window may be tainted
  4. File a GitHub Advisory if you discover new IOCs
  5. 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
hth-github-6.07-supply-chain-compromise-response.sh View source on GitHub ↗
# 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

  1. IR playbook is documented and accessible to the security team
  2. Audit script can enumerate all repos using a specific action across the org
  3. Secret rotation runbook covers org, repo, and environment secrets
  4. Team has practiced the playbook with a tabletop exercise
  5. 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

  1. Navigate to: Organization Settings -> Copilot -> Policies
  2. 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

  1. Organization Settings -> Copilot -> Content exclusion
  2. Add paths to exclude from Copilot context:
    • **/.env* – Environment files
    • **/secrets/** – Secret directories
    • **/*.pem – Certificate files
    • **/*.key – Private keys
    • **/credentials/** – Credential files
  3. Apply per-repository or organization-wide

Step 3: Review Audit Logs

  1. Organization Settings -> Audit log
  2. Filter by action:copilot to see Copilot-related events
  3. Monitor for unusual Copilot usage patterns

Time to Complete: ~15 minutes

Code Implementation

Code Pack: API Script
hth-github-7.02-configure-copilot-governance.sh View source on GitHub ↗
# 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

  1. Navigate to: Organization Settings -> Repository roles
  2. Click “Create a role”
  3. Set role name and description (e.g., “Security Reviewer”)
  4. Select base role (e.g., Read)
  5. 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
  6. Click “Create role”

Step 2: Assign Custom Role

  1. Repository Settings -> Collaborators and teams
  2. Add team or user
  3. Select the custom role from the dropdown

Time to Complete: ~10 minutes per role

Code Implementation

Code Pack: API Script
hth-github-7.03-create-custom-repository-roles.sh View source on GitHub ↗
# 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

  1. Create a .github repository in your organization (if it doesn’t exist)
  2. Add required workflow files (e.g., security-scan.yml, dependency-review.yml)
  3. These workflows will be referenced by the organization ruleset

Step 2: Create Organization Ruleset

  1. Navigate to: Organization Settings -> Rules -> Rulesets
  2. Click “New ruleset” -> “New branch ruleset”
  3. Set name: “Required Security Workflows”
  4. Set enforcement: Active
  5. Set target branches: refs/heads/main, refs/heads/master
  6. Set target repositories: All repositories (exclude .github repo)
  7. Under Rules, add “Require workflows to pass before merging”
  8. Select each required workflow and its source repository
  9. Click “Create”

Step 3: Test Enforcement

  1. Create a test PR in a target repository
  2. Verify the required workflow runs automatically
  3. Confirm the PR cannot be merged until the workflow passes

Time to Complete: ~20 minutes

Code Implementation

Code Pack: API Script
hth-github-7.04-configure-required-workflows.sh View source on GitHub ↗
# 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

  1. Enterprise Settings -> Audit log -> Log streaming
  2. Click “Configure stream”
  3. Choose destination:
    • Amazon S3
    • Azure Blob Storage
    • Azure Event Hubs
    • Datadog
    • Google Cloud Storage
    • Splunk (HTTP Event Collector)
  4. Configure endpoint details
  5. Enable Git events for repository activity
  6. Select event categories to stream
  7. Test connection
  8. Enable stream

Time to Complete: ~30 minutes

Code Implementation

Code Pack: API Script
hth-github-5.07-configure-audit-log-streaming.sh View source on GitHub ↗
# 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
hth-github-5.07-configure-audit-log-streaming.yml View source on GitHub ↗
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
hth-github-5.03-audit-org-webhooks.sh View source on GitHub ↗
# 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
hth-github-5.03-audit-org-webhooks.yml View source on GitHub ↗
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
hth-github-7.01-splunk-detection-queries.spl View source on GitHub ↗
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

  1. Navigate to: Organization page -> Security tab
  2. 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

  1. Filter by severity: Focus on Critical and High alerts first
  2. Filter by repository: Identify highest-risk repositories
  3. Filter by alert type: Address secret scanning alerts immediately (active credentials)

Step 3: Export and Report

  1. Use the Security Overview API to extract metrics for reporting
  2. 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
hth-github-8.01-security-overview-dashboard.sh View source on GitHub ↗
# 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

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

  1. Navigate to: Organization Settings -> Code security -> Configurations

Step 2: Apply GitHub Recommended

  1. Select GitHub recommended configuration
  2. Review included settings:
    • Dependency graph
    • Dependabot alerts and security updates
    • Secret scanning and push protection
    • Code scanning (default setup)
  3. Apply to all repositories

Step 3: Create Custom Configuration (Optional)

  1. For stricter requirements, click “New configuration”
  2. Name it (e.g., “High Security”)
  3. Enable additional settings:
    • Grouped security updates
    • Custom secret scanning patterns
    • Security-extended CodeQL queries
    • Non-provider pattern scanning
  4. Apply to specific repository sets based on sensitivity

Time to Complete: ~15 minutes

Code Implementation

Code Pack: API Script
hth-github-5.08-apply-security-configuration.sh View source on GitHub ↗
# 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
hth-github-5.08-apply-security-configuration.yml View source on GitHub ↗
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

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 repo only, 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-action to assess repository security posture
  • StepSecurity Harden-Runner: Use step-security/harden-runner to 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:

API & Developer Documentation:

GHAS Product Changes (April 2025):

Supply Chain Security Frameworks:

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-claw exploited pull_request_target misconfiguration 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/*/mem and exfiltrated to scan.aquasecurtiy.org.
    • Phase 3 (Mar 22): Cross-org Argon-DevOps-Mgt service account used to deface all 44 internal aquasec-com repos in a 2-minute burst, push malicious Docker Hub images v0.69.5/v0.69.6 (v0.69.6 hijacked latest tag), deploy self-propagating npm worm (CanisterWorm — 141 packages, ICP blockchain C2), and launch geotargeted Kubernetes wiper.
  • 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:


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?


Questions or feedback?

  • GitHub Discussions: [Link]
  • GitHub Issues: [Link]

Sources:

This guide was compiled from the following authoritative sources: