In short

Put the default branch and release tags behind rules that nobody bypasses: no direct pushes, no force pushes or deletions, a pull request with at least one approval from someone other than the last pusher, code owner review for build and release paths, and passing status checks. Require 2FA for everyone with write access, turn on secret push protection, and sign commits and tags. Then verify the effective rules from the API, because a bypass list or a second weaker ruleset quietly undoes them.

  • Do protect release tags as well as branches; a moved tag is how the 2025 tj-actions compromise reached thousands of pipelines.
  • Do keep ruleset bypass lists empty; an admin who can bypass review is a single point of compromise.
  • Do require approval of the most recent push and dismiss stale approvals, so the reviewed revision is the merged one.
  • Do put CODEOWNERS on workflows, release scripts, lockfiles and the CODEOWNERS file itself.
  • A commit signature proves which key signed, not that anyone reviewed the change; the Git author field proves nothing.
  • SLSA Source L3 needs continuously enforced controls on protected branches, and L4 adds two-party review of the final revision.

Verify it

  1. Run gh ruleset check --default and confirm deletion and force pushes are blocked and a pull request with at least one approval and required checks is required.
  2. List every ruleset's bypass_actors and confirm the lists are empty or limited to audited release automation.
  3. Try to push directly to the default branch and to move a release tag in the repository; both pushes must be rejected.
  4. Open a pull request that edits .github/workflows/ and confirm it requests review from the owners in CODEOWNERS.
  5. Run git log --show-signature on recent commits and tags and confirm each verifies against a key in your allowed signers file.
  6. Run Scorecard's Branch-Protection and Code-Review checks, with an admin token if the repository still uses classic branch protection rules, and record the scores.
Applies to

Git repositories on GitHub or GitLab that produce software you build, release or deploy, whether open source or internal.

Part of
Learning path: From source to trusted release (step 6 of 12). Workflow: Build an AppSec program
Learning goals and prerequisitesAbout 31 minutes: 11 to read and 20 for the exercise

What you will learn

  • Configure branch and tag rulesets that block direct, force and self-approved changes, with no standing bypass.
  • Use CODEOWNERS to route changes to build, release and security-sensitive paths to the right reviewers.
  • Sign commits and tags, and explain what a signature does and does not prove.
  • Map repository settings to the SLSA Source track levels, OSPS Baseline controls and Scorecard checks, and verify them from the API.

Before you start

Maintainers, repository administrators and AppSec or platform engineers who set the rules for repositories that build and release software

  • Know the pull request flow on GitHub or GitLab: branches, reviews, status checks and merges.
  • Admin access to a test repository, the GitHub CLI (gh) and Git 2.34 or later to try the commands.

Refresh first: Protect the path from pull request to release

Go to the guided practice

What goes wrong in a source repository?

Everything a build produces is only as trustworthy as the repository it builds from. The attacks that matter are mundane: a stolen maintainer token pushes straight to main; a contributor approves their own change through a second account; a force push rewrites history or moves a release tag; a change to a workflow file or release script slips through review because nobody responsible for releases saw it; a secret is committed and scraped within minutes. The Git author and committer fields are free text, so a commit that claims to come from a trusted maintainer proves nothing on its own.

Two incidents on this site show the pattern. In /domains/supply-chain/ci-cd/case-study-tj-actions/, an attacker with a stolen token moved existing version tags to a malicious commit, so every workflow pinned to a tag ran it. In /domains/supply-chain/dependencies/case-study-xz-utils/, a patient contributor earned commit and release rights, which no repository setting can prevent but which review rules and more than one trusted maintainer make harder to abuse.

Protect the default branch and release tags with rulesets

Rulesets are GitHub's newer way to protect branches and tags, and they work alongside classic branch protection rules. Several rulesets can apply to one branch at once, their rules are aggregated with any branch protection rule and the most restrictive value wins, anyone with read access can see the active rulesets, and a ruleset can be switched off without deleting it. The ruleset below protects the default branch: it blocks deletion and force pushes, requires verified signatures, and requires a pull request with one approval from someone other than the last pusher, code owner review, resolved conversations and a passing test check from the GitHub Actions app (integration 15368), so another app cannot post a status with the same name.

Keep bypass_actors empty. Every entry is a person or app that can push around review, and a compromised admin account is exactly the case the rules exist for. Release tags need their own ruleset: blocking deletion, force pushes and updates on refs/tags/v* stops a tag from being moved after release. Add a creation rule as well if a new tag triggers your release workflow, and give that bypass only to the release automation. Immutable releases, generally available since 2025-10-28, also lock the tag and assets of each new release and add a signed release attestation.

Configurationdefault-branch.jsonJSON
{
  "name": "protect-default-branch",
  "target": "branch",
  "enforcement": "active",
  "bypass_actors": [],
  "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}},
  "rules": [
    {"type": "deletion"},
    {"type": "non_fast_forward"},
    {"type": "required_signatures"},
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": true,
        "require_last_push_approval": true,
        "required_review_thread_resolution": true,
        "allowed_merge_methods": ["squash"]
      }
    },
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": true,
        "required_status_checks": [{"context": "test", "integration_id": 15368}]
      }
    }
  ]
}

Apply the rulesets from the command line

Rulesets are plain JSON, so keep them in a repository that configures your organization and apply them with the REST API. Organization rulesets, on GitHub Enterprise, apply the same rules across many repositories, which avoids one forgotten repository without protection.

apply-rulesets.shShell
# Create the branch ruleset above, then one for release tags
gh api --method POST repos/example-org/payments-api/rulesets --input default-branch.json

gh api --method POST repos/example-org/payments-api/rulesets --input - <<'EOF'
{
  "name": "protect-release-tags",
  "target": "tag",
  "enforcement": "active",
  "bypass_actors": [],
  "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}},
  "rules": [
    {"type": "deletion"},
    {"type": "non_fast_forward"},
    {"type": "update", "parameters": {"update_allows_fetch_and_merge": false}}
  ]
}
EOF

Route sensitive changes to the right reviewers with CODEOWNERS

A required approval only helps if the approver understands what the change does. CODEOWNERS assigns owners to paths; with require_code_owner_review on, a pull request that touches an owned path cannot merge until one of its owners approves. Put the file in .github/, the repository root or docs/ (GitHub uses the first it finds in that order), list teams rather than individuals, and remember that the last matching pattern wins and that owners need write access. One owner from the list is enough, so a line with two teams means either team, not both.

Own the paths that change how software is built and released, not only the code: workflow files, release scripts, Dockerfiles, lockfiles and package manifests, authentication code, and the .github/ directory, which also covers CODEOWNERS itself. Without that last entry, a contributor can change who must review their next change. /domains/appsec/code-review/security-code-review/ covers what those reviewers should look for.

Configuration.github/CODEOWNERSPlain text
# .github/CODEOWNERS (the last matching pattern wins)
*                          @example-org/payments-maintainers

# Changes that alter how code is built, released or reviewed
/.github/                  @example-org/security-reviewers
/.github/workflows/        @example-org/security-reviewers @example-org/platform
/scripts/release/          @example-org/security-reviewers
/Dockerfile                @example-org/platform
/package-lock.json         @example-org/payments-maintainers
/src/auth/                 @example-org/security-reviewers

Sign commits and tags, and know what a signature proves

A signature binds a commit or tag to a key. With the public key registered on the account, GitHub marks the commit Verified and the required_signatures rule accepts it. That defeats the cheapest spoof, a commit whose author field names a maintainer, and it makes a release tag traceable to the person who cut it. It does not prove the change was reviewed, that the signer's machine was clean, or that the key is still controlled by its owner, so signing complements review rules and does not replace them.

SSH signing, available since Git 2.34, is the simplest option because developers already have SSH keys; a FIDO2 key such as ed25519-sk keeps the private key on hardware. Turn on vigilant mode (Settings, SSH and GPG keys, Flag unsigned commits as unverified) so unsigned commits that claim your identity are marked Unverified. gitsign signs keylessly with a short-lived Sigstore certificate tied to your OIDC identity and records the signature in the Rekor transparency log; verify it with gitsign verify and the expected identity and issuer. As of gitsign 0.17, GitHub does not recognize these signatures as Verified, so a repository that uses the required_signatures rule needs SSH or GPG signing.

With required_signatures on, a squash merge is signed by GitHub, but unsigned commits on the head branch can still block it. GitLab's equivalent is the Reject unsigned commits push rule (Premium and Ultimate), which checks commits pushed from Git clients but not commits created in the GitLab UI or API.

sign-commits.shShell
# Sign every commit and tag with an SSH key (Git 2.34 or later).
# A FIDO2 key keeps the private key on hardware: ssh-keygen -t ed25519-sk
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_sk.pub
git config --global commit.gpgsign true
git config --global tag.gpgSign true

# Add the same public key to GitHub as a *signing* key (Settings > SSH and GPG keys),
# then verify locally against the keys your project trusts:
echo "alice@example.com namespaces=\"git\" $(cut -d' ' -f1,2 ~/.ssh/id_ed25519_sk.pub)" \
  >> ~/.config/git/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
git log --show-signature -1
git verify-tag v2.4.0

# Keyless alternative: gitsign signs with a short-lived Sigstore certificate.
# GitHub shows these commits as Unverified, so they do not satisfy required_signatures.
git config --local gpg.x509.program gitsign
git config --local gpg.format x509
gitsign verify --certificate-identity=alice@example.com \
  --certificate-oidc-issuer=https://github.com/login/oauth HEAD

Stop secrets at push time

Secret scanning push protection rejects a push, a web commit, a file upload or an API request that contains a recognized credential, before it lands in history where it is effectively public. Push protection for users is on by default on GitHub.com for pushes to public repositories; repository and organization push protection needs GitHub Secret Protection and covers private repositories too. Anyone with write access can bypass a block by choosing a reason, which creates an alert and an audit log entry, so turn on delegated bypass where you want a security reviewer to approve each one. GitLab offers secret push protection on Ultimate; a push can skip it with git push -o secret_push_protection.skip_all, and every skip is audited.

Push protection only knows patterns it can match. Keep pre-commit scanning on developer machines and treat any secret that reached a remote as leaked: /domains/identity-crypto/secrets/secrets-lifecycle/ and /domains/identity-crypto/secrets/leaked-secret-response/ cover the rest.

Control who can change the repository

  • Require 2FA for every member and outside collaborator at the organization level, and prefer passkeys or security keys. GitHub has required 2FA for code contributors on GitHub.com since 2023, but your organization should enforce it rather than rely on that program.
  • Give new collaborators the lowest role that works, and review write and admin access each quarter; remove people who have left and collaborators who no longer contribute.
  • Prefer GitHub Apps and fine-grained personal access tokens with an expiry and a single repository over classic tokens; make deploy keys read-only unless a job must push.
  • Restrict who can change repository settings and rulesets, and alert on changes to them from the audit log.
  • Have more than one trusted maintainer with release rights, so a single account takeover or burnout does not decide what ships.

The same controls on GitLab

The same controls on GitLab
ControlGitHubGitLab
Block direct pushes and force pushesRuleset: pull_request and non_fast_forward rulesProtected branch: no one allowed to push, force push off
Required approvals, not by the authorpull_request rule with required approvals and last-push approvalMerge request approval rules and Prevent approval by merge request creator (Premium)
Re-approve after new commitsdismiss_stale_reviews_on_pushRemove all approvals when commits are added to the source branch (Premium)
Code owner reviewCODEOWNERS in .github/, root or docs/, with require_code_owner_reviewCODEOWNERS in root, docs/ or .gitlab/, with Required approval from code owners on the protected branch (Premium)
Status checksrequired_status_checks rulePipelines must succeed merge check
Signed commitsrequired_signatures ruleReject unsigned commits push rule (Premium); UI and API commits are exempt
Secret push protectionPush protection (GitHub Secret Protection)Secret push protection (Ultimate)
Release tagsTag ruleset or immutable releasesProtected tags

Map the controls to SLSA Source, OSPS Baseline and Scorecard

The SLSA Source track, approved in SLSA v1.2, grades how far a consumer can trust that a revision came through the organization's intended process. Source L1 means the source is in a modern version control system with immutable, uniquely identifiable revisions. L2, History and Provenance, requires continuous, immutable and retained branch history, which in Git means a technical control against git push --force, protected tags, identity management, and source provenance for each new revision. L3, Continuous technical controls, requires the system to enforce the organization's controls on protected named references, with evidence that they were enforced continuously. L4 adds two-party review: the uploader and a reviewer, or two reviewers, must be different trusted persons, and the approval must cover the final revision. The source control system also issues Source Verification Summary Attestations so consumers can check the level.

The OpenSSF Open Source Project Security Baseline (OSPS Baseline), current version v2026.08.28, sets controls by maturity level: Level 1 for any code or non-code project, Level 2 for code projects with at least two maintainers and a small number of consistent users, and Level 3 for code projects with a large number of consistent users. OpenSSF Scorecard v5.5.0 measures many of the same settings automatically. The table maps the controls on this page to all three; /domains/supply-chain/ci-cd/osps-baseline-for-maintainers/ walks through every Baseline level, with the evidence to keep for each Level 1 requirement.

Scorecard and the Baseline describe a project you maintain. The OpenSSF Secure Supply Chain Consumption Framework (S2C2F) is the consumer-side counterpart: eight practices (ingest, scan, inventory, update, audit, enforce, rebuild, and fix and upstream) at four maturity levels for how you take in open source. /domains/supply-chain/dependencies/s2c2f-adoption/ walks through its requirements and levels, and /domains/supply-chain/dependencies/project-health-and-intake/ covers intake decisions for one package.

Map the controls to SLSA Source, OSPS Baseline and Scorecard
ControlSLSA Source trackOSPS BaselineScorecard check
MFA for everyone with write accessIdentity management (L2+)OSPS-AC-01.01 (Level 1)None
Lowest privilege for new collaboratorsIdentity management (L2+)OSPS-AC-02.01 (Level 1)None
Block force pushes; protect tagsHistory and continuity (L2+)None directlyBranch-Protection tier 1 (force pushes only; tags are not checked)
Block deletion of the primary branchProtected named references (L3+)OSPS-AC-03.02 (Level 1)Branch-Protection tier 1
Block direct commits to the primary branchContinuous technical controls (L3+)OSPS-AC-03.01 (Level 1)Branch-Protection tier 2
Required status checksContinuous technical controls (L3+)OSPS-QA-03.01 (Level 2)Branch-Protection tier 3, CI-Tests
Non-author approval of the final revisionTwo-party review (L4)OSPS-QA-07.01 (Level 3)Code-Review; Branch-Protection tiers 2 and 4
No secrets in version controlNot coveredOSPS-BR-07.01 (Level 1)None
Signed releasesNot covered (Build track)OSPS-BR-06.01 (Level 2)Signed-Releases

Check that the rules actually hold

Read the effective rules, not the settings page you remember. gh ruleset check and the branch rules endpoint return every rule that applies to a branch from every ruleset and level; rulesets in evaluate or disabled mode are not returned. Then list each ruleset's bypass actors, and run Scorecard on a schedule so a weakened setting shows up as a lower score. Finally, test the controls the way an attacker would: push directly to main, approve your own pull request from a second account, push a new commit after approval and try to merge, and move a release tag in a test repository. Each should fail.

Testaudit-repository.shShell
# 1. Every rule that applies to the default branch, from all rulesets and levels
gh ruleset check --default --repo example-org/payments-api

# 2. The same as JSON, summarized; several rulesets can each add a pull_request rule,
#    and the most restrictive value applies, so take the maximum
gh api repos/example-org/payments-api/rules/branches/main --jq '
  [.[] | select(.type == "pull_request") | .parameters] as $pr
  | {deletion_blocked: any(.[]; .type == "deletion"),
     force_push_blocked: any(.[]; .type == "non_fast_forward"),
     signatures_required: any(.[]; .type == "required_signatures"),
     approvals: ([$pr[].required_approving_review_count] | max // 0),
     code_owner_review: any($pr[]; .require_code_owner_review),
     last_push_approval: any($pr[]; .require_last_push_approval),
     status_checks: [.[] | select(.type == "required_status_checks")
                     | .parameters.required_status_checks[].context]}'

# 3. Who can bypass? An empty list is the goal
for id in $(gh api repos/example-org/payments-api/rulesets --jq '.[].id'); do
  gh api repos/example-org/payments-api/rulesets/$id --jq '{name, bypass_actors}'
done

# 4. OpenSSF Scorecard checks that read the same settings (needs GITHUB_AUTH_TOKEN)
scorecard --repo=github.com/example-org/payments-api \
  --checks=Branch-Protection,Code-Review,Signed-Releases,Token-Permissions,Dangerous-Workflow \
  --show-details

Guided practice

You are auditing payments-api after a near miss. The settings export and event log below are complete. Decide which controls failed and what to change.

Supplied scenario material
Plain text
Ruleset 'main-protection' (active): include ~DEFAULT_BRANCH; rules deletion, pull_request {required_approving_review_count: 1, dismiss_stale_reviews_on_push: false, require_code_owner_review: false, require_last_push_approval: false}; bypass_actors: [{actor_type: RepositoryRole, actor_id: 5 (admin), bypass_mode: always}].
Ruleset 'legacy' (disabled): non_fast_forward, required_status_checks [test].
No tag rulesets. Immutable releases off. Push protection off. Org 2FA requirement off.
CODEOWNERS: '* @example-org/payments-maintainers' only.

Events (last 30 days):
E1 Admin account 'ops-bot-admin' pushed directly to main (bypass) a change to .github/workflows/release.yml.
E2 Pull request #812 approved by bob, then the author pushed one more commit and merged without new review.
E3 Tag v3.2.0 was force-pushed to a different commit two days after release.
E4 A commit with author 'alice@example.com' arrived unsigned; Alice says she did not write it.
E5 A contributor's pull request added a line to CODEOWNERS removing payments-maintainers from /src/auth/.
  1. 1. Explain E1

    Which setting allowed the direct push to a release workflow, and what replaces it?

    Expected result and reasoning

    The admin role is a bypass actor with bypass_mode always, so the account skipped the pull request rule. Remove the bypass, require code owner review, and add CODEOWNERS entries for .github/workflows/ owned by security reviewers. Investigate E1 as a possible account compromise and require org-wide 2FA.

  2. 2. Explain E2

    Why could #812 merge after an unreviewed push?

    Expected result and reasoning

    dismiss_stale_reviews_on_push and require_last_push_approval are false, so the approval of an earlier revision still counted. Turn both on, so the merged revision is the reviewed one; that is also what SLSA Source L4 two-party review requires of the final revision.

  3. 3. Explain E3

    Why could v3.2.0 move, and what stops it?

    Expected result and reasoning

    There is no tag ruleset and immutable releases are off; the non_fast_forward rule exists only in a disabled ruleset and targets branches. Add a tag ruleset on refs/tags/v* with deletion, non_fast_forward and update rules, or turn on immutable releases, and investigate who moved the tag and what consumers pulled it.

  4. 4. Explain E4

    What does the unsigned commit tell you, and what control helps?

    Expected result and reasoning

    The author field is free text, so the commit proves nothing about Alice. Require signed commits on main, have maintainers enable vigilant mode so unsigned commits under their identity show as Unverified, and find which account pushed the commit from the audit log.

  5. 5. Explain E5 and rank the fixes

    Why is E5 dangerous, and in what order would you apply all the changes?

    Expected result and reasoning

    Changing CODEOWNERS changes who must review future changes, and the only owner of everything is the team being removed, so it reviews its own demotion. Own /.github/ (which covers CODEOWNERS) by security reviewers. Order: remove the bypass and require 2FA first, then stale-review and last-push approval, re-enable force-push blocking and status checks in the active ruleset, add the tag ruleset, CODEOWNERS for sensitive paths, signed commits and push protection.

Your deliverable: A findings table (event, failed control, evidence, fix) and the corrected ruleset JSON for the default branch and release tags.

The repository, accounts and events are invented. Real investigations need the organization audit log and the push events behind each change; this exercise only tests whether the settings could have prevented them.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. SLSA v1.2: Source track requirements. v1.2 (approved November 2025).
  2. OpenSSF OSPS Baseline v2026.08.28. Released 28 August 2026.
  3. OpenSSF Scorecard: checks documentation
  4. OpenSSF S2C2F: Secure Supply Chain Consumption Framework. Version 1.1 (October 2022), Markdown revised August 2024.
  5. GitHub Docs: Available rules for rulesets
  6. GitHub REST API: Repository rules
  7. GitHub Docs: About code owners
  8. GitHub Docs: Displaying verification statuses for all of your commits (vigilant mode)
  9. GitHub Docs: Push protection
  10. sigstore/gitsign: README
  11. GitHub changelog: Immutable releases are now generally available (2025-10-28)
  12. GitLab Docs: Push rules
  13. OWASP Top 10 CI/CD Security Risks: CICD-SEC-1 Insufficient Flow Control Mechanisms. v1.0 (2022).
  14. CISA, NSA and ODNI (Enduring Security Framework): Securing the Software Supply Chain, Recommended Practices Guide for Developers, section 2.2.1 Modification or Exploitation of Source Code by Insiders. August 2022.

Reference information

Download this guide as Markdown