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.
| Setting | Value | Note | Source |
|---|---|---|---|
| Default branch | Ruleset: restrict deletions, block force pushes, require a pull request, required status checks | Both deletion and force-push rules are selected by default when you create a ruleset in the UI. | GitHub Docs: available rules for rulesets |
| Pull request rule | 1+ approval, dismiss stale approvals, require approval of the most recent push, code owner review | Two approvals for repositories with enough maintainers (Scorecard Branch-Protection tier 4). | GitHub REST API: repository rulesets |
| Bypass actors | None | If you need break-glass access, grant it to a team with audit and review every use. | OpenSSF Scorecard Branch-Protection check |
| Release tags | Tag ruleset on refs/tags/v*: deletion, non_fast_forward and update rules | Or enable immutable releases (GA 2025-10-28), which lock the tag and assets of new releases. | GitHub changelog, 2025-10-28 |
| Secret push protection | On for every repository | Repository-level push protection needs GitHub Secret Protection; push protection for users covers pushes to public repositories by default. | GitHub Docs: about push protection |
| Commit signing | SSH signing (Git 2.34+) or GPG; gitsign for keyless | GitHub does not show gitsign signatures as Verified. | sigstore/gitsign README |
| OSPS Baseline | v2026.08.28 (current) | Three maturity levels; Level 1 applies to any project. | baseline.openssf.org |
| OpenSSF Scorecard | v5.5.0 (scorecard-action v2.4.4) | Branch-Protection needs an admin token for some classic branch protection settings; ruleset settings are readable without one. | ossf/scorecard releases |
- Run
gh ruleset check --defaultand confirm deletion and force pushes are blocked and a pull request with at least one approval and required checks is required. - List every ruleset's
bypass_actorsand confirm the lists are empty or limited to audited release automation. - Try to push directly to the default branch and to move a release tag in the repository; both pushes must be rejected.
- Open a pull request that edits
.github/workflows/and confirm it requests review from the owners in CODEOWNERS. - Run
git log --show-signatureon recent commits and tags and confirm each verifies against a key in your allowed signers file. - 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 prerequisites
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 practiceEverything 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.
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.
{
"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}]
}
}
]
}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.
# 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}}
]
}
EOFA 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.
# .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-reviewersA 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 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 HEADSecret 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.
- 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.
| Control | GitHub | GitLab |
|---|---|---|
| Block direct pushes and force pushes | Ruleset: pull_request and non_fast_forward rules | Protected branch: no one allowed to push, force push off |
| Required approvals, not by the author | pull_request rule with required approvals and last-push approval | Merge request approval rules and Prevent approval by merge request creator (Premium) |
| Re-approve after new commits | dismiss_stale_reviews_on_push | Remove all approvals when commits are added to the source branch (Premium) |
| Code owner review | CODEOWNERS in .github/, root or docs/, with require_code_owner_review | CODEOWNERS in root, docs/ or .gitlab/, with Required approval from code owners on the protected branch (Premium) |
| Status checks | required_status_checks rule | Pipelines must succeed merge check |
| Signed commits | required_signatures rule | Reject unsigned commits push rule (Premium); UI and API commits are exempt |
| Secret push protection | Push protection (GitHub Secret Protection) | Secret push protection (Ultimate) |
| Release tags | Tag ruleset or immutable releases | Protected tags |
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.
| Control | SLSA Source track | OSPS Baseline | Scorecard check |
|---|---|---|---|
| MFA for everyone with write access | Identity management (L2+) | OSPS-AC-01.01 (Level 1) | None |
| Lowest privilege for new collaborators | Identity management (L2+) | OSPS-AC-02.01 (Level 1) | None |
| Block force pushes; protect tags | History and continuity (L2+) | None directly | Branch-Protection tier 1 (force pushes only; tags are not checked) |
| Block deletion of the primary branch | Protected named references (L3+) | OSPS-AC-03.02 (Level 1) | Branch-Protection tier 1 |
| Block direct commits to the primary branch | Continuous technical controls (L3+) | OSPS-AC-03.01 (Level 1) | Branch-Protection tier 2 |
| Required status checks | Continuous technical controls (L3+) | OSPS-QA-03.01 (Level 2) | Branch-Protection tier 3, CI-Tests |
| Non-author approval of the final revision | Two-party review (L4) | OSPS-QA-07.01 (Level 3) | Code-Review; Branch-Protection tiers 2 and 4 |
| No secrets in version control | Not covered | OSPS-BR-07.01 (Level 1) | None |
| Signed releases | Not covered (Build track) | OSPS-BR-06.01 (Level 2) | Signed-Releases |
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.
# 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-detailsGuided 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
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. 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_modealways, 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. Explain E2
Why could #812 merge after an unreviewed push?
Expected result and reasoning
dismiss_stale_reviews_on_pushandrequire_last_push_approvalarefalse, 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. 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_forwardrule exists only in a disabled ruleset and targets branches. Add a tag ruleset onrefs/tags/v*withdeletion,non_fast_forwardandupdaterules, or turn on immutable releases, and investigate who moved the tag and what consumers pulled it.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. 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.
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.
- SLSA v1.2: Source track requirements. v1.2 (approved November 2025).
- OpenSSF OSPS Baseline v2026.08.28. Released 28 August 2026.
- OpenSSF Scorecard: checks documentation
- OpenSSF S2C2F: Secure Supply Chain Consumption Framework. Version 1.1 (October 2022), Markdown revised August 2024.
- GitHub Docs: Available rules for rulesets
- GitHub REST API: Repository rules
- GitHub Docs: About code owners
- GitHub Docs: Displaying verification statuses for all of your commits (vigilant mode)
- GitHub Docs: Push protection
- sigstore/gitsign: README
- GitHub changelog: Immutable releases are now generally available (2025-10-28)
- GitLab Docs: Push rules
- OWASP Top 10 CI/CD Security Risks: CICD-SEC-1 Insufficient Flow Control Mechanisms. v1.0 (2022).
- 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.