You sign your images, scan your dependencies, generate attestations. But who
can change your source code? If any account can push to main or move a
release tag, all the security downstream can be bypassed from upstream.
This lesson is for developers and repository owners who want no change to
reach the main branch without explicit validation. It covers GitHub
rulesets and GitLab protected branches, tag protection, the CODEOWNERS
file and the documentation of contribution rules.
What you will learn
- Understand why a repository without rules is the first risk of a CI/CD chain, through the tj-actions attack
- Protect the main branch with a GitHub ruleset or a GitLab protected branch, then check it
- Lock release tags, which branch protection does not cover
- Designate the mandatory reviewers of sensitive areas with
CODEOWNERS - Place your configuration within SLSA Source and the Scorecard score
- SOCLE anchoring: this lesson produces the evidence for SOCLE-SRC-GEN-1 (in French) and for the branch protection and review requirements
Why govern source code?
Governing source code means deciding who can change what, and under which validation. Without that decision, the rest of the chain protects code whose origin nobody vouches for. The OWASP Top 10 CI/CD puts this risk first, as CICD-SEC-1, "insufficient flow control mechanisms": an attacker who gets write access pushes their code all the way to production without meeting a single reviewer.
What a repository without rules allows
On GitHub as on GitLab, a freshly created repository enforces almost nothing.
Any collaborator with write access can push straight to main, merge a
pull request without approval, edit a workflow to read the CI secrets, or
move a release tag to another commit. And after an incident, nothing lets
you reconstruct who validated what.
A compromised account or a malicious contributor can then inject a backdoor into the code, edit a workflow to steal tokens, tamper with a Dockerfile to trap the image, or swap a dependency for a malicious package. Each of these changes reaches production through the normal path, since nothing stops it.
What the tj-actions attack shows
In March 2025, the GitHub action tj-actions/changed-files, used by more than
23,000 repositories, was compromised (CVE-2025-30066). The attacker held a
personal access token stolen from a bot that had write access to the
repository. They touched no branch at all: they rewrote the release tags to
point at a malicious commit, which printed the pipelines' secrets into their
build logs.
The lesson cuts both ways. On the publisher side, branch protection only protects branches: tags need their own rule, and a broadly scoped bot token is a key to the whole house. On the consumer side, only pinning by commit SHA protected projects, which is covered in Pinning actions by SHA. The risk of overly broad tokens is developed in OWASP Top 10 CI/CD.
Which mechanisms should you combine?
Source code governance rests on four complementary mechanisms. The table says what each one brings and what happens when it is missing. The first one is the foundation: without it, the others remain suggestions anyone can ignore.
| Mechanism | Role | Without it |
|---|---|---|
| Branch protection (ruleset) | Forbids direct pushes, requires a validated pull request and a green CI | Any account changes main |
| Tag protection | Forbids moving or deleting a release tag | A published version changes content silently |
| CODEOWNERS | Names who must approve each area of the code | Any collaborator approves |
| CONTRIBUTING.md | Documents the rules for contributors | Fuzzy rules, honest mistakes |
Step 1: protect the main branch
Branch protection is the repository's automatic gatekeeper: it refuses any
direct change to main and requires going through a validated pull
request. On GitHub, the current form is the ruleset, which replaces the
classic rules and can be read without administration rights, which matters to
audit tools such as Scorecard.
What a branch ruleset controls
A ruleset is not a single box but a set of locks you enable separately. The table maps each rule to what it prevents. Three deserve your attention first: the mandatory pull request, the approval of the most recent push, which stops anyone adding a commit after the review, and an empty bypass list, without which an administrator overrides every rule.
| Rule (GitHub interface) | What it prevents |
|---|---|
| Require a pull request before merging | Direct push to the protected branch |
| Required approvals | Merging without human validation |
| Dismiss stale pull request approvals when new commits are pushed | An approval kept after the code changed |
| Require approval of the most recent reviewable push | A commit added after the review, without fresh approval |
| Require review from Code Owners | Merging without the designated owners' approval |
| Require status checks to pass | Merging with a failing CI |
| Block force pushes | Rewriting the branch history |
| Restrict deletions | Deleting the protected branch |
Configuring a ruleset on GitHub
The configuration lives in the repository settings, or in the organisation settings to apply it to several repositories at once. Each step below names the menu to open and the rules to enable.
-
Create the ruleset
In the repository, open Settings, then Rules, Rulesets, and choose New ruleset, then New branch ruleset.
-
Name and target the rule
- Ruleset name:
Protect main - Enforcement status: Active
- Target branches: Add target, then Include default branch
Add other targets by pattern if needed, such as
release/*. - Ruleset name:
-
Enable the essential rules
- Restrict deletions and Block force pushes
- Require a pull request before merging, with at least 1 approval (2 for a sensitive repository), Dismiss stale pull request approvals, Require review from Code Owners and Require approval of the most recent reviewable push
- Require status checks to pass, with your CI jobs (tests, lint, build)
-
Empty the bypass list
At the top of the page, the Bypass list must stay empty. A role listed there, administrators included, merges without following the rules, which defeats the purpose of the ruleset.
-
Save
Click Create. The ruleset applies immediately.
Configuring a protected branch on GitLab
GitLab has no ruleset: protection is set per branch, and approvals are set separately. Approval rules and code owner approval require the Premium or Ultimate tier; on the free tier, only push and merge restrictions are available.
-
Protect
mainUnder Settings, Repository, Protected branches: pick
main, set Allowed to merge to Maintainers, Allowed to push and merge to No one, and leave Allowed to force push off. -
Require code owner approval
On the same row, enable Code owner approval, if you have a
CODEOWNERSfile. -
Block risky merges
Under Settings, Merge requests, tick Pipelines must succeed and All threads must be resolved.
-
Set the number of approvals
In the Merge request approvals section, create a rule with at least 1 required approval, and remove the author's right to approve their own merge request.
Checking that the protection works
An untested protection is a hypothesis. The simplest test is to try a
direct push to main from an up-to-date clone: the server must refuse
it, not accept it and flag it afterwards.
git switch mainecho "# Protection test" >> README.mdgit commit -am "test: a direct push must fail"git push origin mainWith a ruleset, GitHub answers with a GH013 error listing the violated rule; classic protection answers with the GH006 error.
remote: error: GH013: Repository rule violations found for refs/heads/main.remote: - Changes must be made through a pull request.If the push goes through, check the ruleset's target and its bypass
list. Then drop the test local commit with git reset --hard origin/main.
Step 2: protect the tags too
Branch protection does not cover tags, and that is exactly the path the
tj-actions attack took. A release tag such as v1.4.0 is a name
consumers believe to be frozen: if it can be moved, a published version
can change content without anyone noticing.
On GitHub, a tag ruleset is created in the same place as branch rulesets,
with New tag ruleset. Target every release tag, for example the v*
pattern, and enable Restrict updates and Restrict deletions: a published
tag can then be neither moved nor deleted, except by a role in the bypass
list, which must stay empty.
GitHub also offers immutable releases: once the option is enabled in the
repository settings, a published release, its tag and its files can no longer
be modified. That setting is what protected version v0.35.0 of
trivy-action during the March 2026 compromise, detailed in the
Security testing
lesson. On GitLab, the equivalent is Settings, Repository, Protected
tags, where Allowed to create is set to Maintainers.
Step 3: designate owners with CODEOWNERS
A protection that requires "1 approval" does not say by whom. By default,
any collaborator with write access approves, and an attacker who controls two
accounts, or has an accomplice, approves their own change. The CODEOWNERS
file settles that: it designates the mandatory reviewers of each area of
the code, and the forge adds them automatically to every pull request touching
it.
How CODEOWNERS works
When a pull request opens, the forge looks at the changed files, finds in
CODEOWNERS the last rule matching each one, and adds the owners found as
reviewers. Their approval only becomes mandatory if the "Require review
from Code Owners" rule is active in branch protection: without it, the file is
merely a suggestion.
Creating the CODEOWNERS file
On GitHub, the file goes in .github/CODEOWNERS, at the root or in
docs/; on GitLab, at the root, in docs/ or in .gitlab/. The example
below starts with a default owner, then protects the areas that give access
to secrets, to the image or to the dependencies.
# The LAST matching rule wins:# general rule first, specific rules after.
# Default owner of everything below* @acme/maintainers
# CI/CD workflows: they read the repository secrets.github/workflows/ @acme/security.github/actions/ @acme/security.gitlab-ci.yml @acme/security
# Build and deployment: a trapped Dockerfile traps the imageDockerfile @acme/opsdocker-compose*.yml @acme/opshelm/ @acme/opsterraform/ @acme/ops
# Dependencies: the chain's first attack vectorpackage.json @acme/securitypackage-lock.json @acme/securityrequirements*.txt @acme/securitygo.mod @acme/securitygo.sum @acme/security
# The CODEOWNERS file itself.github/CODEOWNERS @acme/securityThe last line matters more than it looks: without it, a contributor could
remove an area's owners by editing CODEOWNERS in the same pull request
as their change, and ask nobody's opinion.
Reading the CODEOWNERS syntax
Patterns follow the .gitignore syntax, with one big difference: order
matters, since the last matching rule wins. The table sums up the useful
forms and their scope.
| Pattern | Meaning | Example |
|---|---|---|
* | Every file (default rule) | * @acme/maintainers |
*.py | Every file with this extension | *.py @acme/python |
/src/ | The src/ folder at the root only | /src/ @acme/core |
tests/ | Any tests/ folder anywhere | tests/ @acme/qa |
@alice | A user account | docs/ @alice |
@acme/security | A team in the organisation | go.mod @acme/security |
Checking that CODEOWNERS works
GitHub flags syntax errors directly when it displays the CODEOWNERS file, but
the only real test is a pull request touching a protected area. The
designated reviewers must appear automatically, and merging must stay
blocked until they approve.
git switch -c test/codeownersecho "# test" >> .github/workflows/ci.ymlgit commit -am "test: check CODEOWNERS"git push origin test/codeownersOpen the pull request to main: the @acme/security team must appear among
the required reviewers, and the merge button must show "Review required from
code owners" as long as the approval is missing.
Step 4: document the rules with CONTRIBUTING.md
CODEOWNERS says who approves, CONTRIBUTING.md explains how to
contribute. The document prevents honest mistakes (badly named branch, pull
request without a description), spares reviewers repeating the same rules, and
serves as audit evidence that the process was written down before the
incident. GitHub shows it automatically if it sits at the root, in .github/
or in docs/.
The template below fits on one page, on purpose: an overly long contribution guide goes unread. It names the sensitive areas and their owners, lists what is forbidden, and says where to report a flaw.
# Contributing
Every change goes through a pull request to `main`.
## Process
1. Create a branch from `main`: `feature/…`, `fix/…`, `security/…`.2. Open a pull request that explains the why, not just the what, and links the related issue (`Fixes #123`).3. Wait for a green CI and at least one approval. Sensitive areas need their owners' approval (CODEOWNERS file).
## Sensitive areas
| Area | Risk | Owners ||------|------|--------|| `.github/workflows/` | Access to CI/CD secrets | @acme/security || `Dockerfile`, `helm/` | Trapped image or deployment | @acme/ops || dependency files | Supply chain attack | @acme/security |
## Forbidden
- Pushing straight to `main` or force pushing to a protected branch.- Merging without the required owners' approval or with a red CI.- Disabling a CI check to force a merge through.
## Reporting a vulnerability
Do not open a public issue: use the repository's Security tab("Report a vulnerability") or write to security@acme.example.We acknowledge within 48 hours.Which protection level should you aim for?
Three tiers stand out, from the minimum every repository should apply to the configuration of sensitive repositories. Each one closes one more attack, and each maps to requirements of the SOCLE baseline, quoted in brackets to serve as evidence.
The minimum, for every repository
This first tier closes the widest gap, the direct push to main, and
requires a review and a green CI before any merge. It needs no team
organisation: a few repository settings are enough, and a personal project can
apply it from day one.
- Mandatory pull request and at least one approval (SOCLE-SRC-GEN-1).
- Required status checks (SOCLE-SRC-BRA-1).
- Force push and deletion forbidden on
main(SOCLE-SRC-SIG-1).
The standard tier, for a team
This is the tier most teams aim for. It adds the building block that prevents self-approval and locks out bypassing by administrators: at this level, a single compromised account is no longer enough to slip malicious code through.
CODEOWNERSactive with "Require review from Code Owners" (SOCLE-SRC-BRA-2).- Approvals dismissed on every new push (SOCLE-SRC-BRA-3), approval of the most recent push required, empty bypass list.
- Release tags protected against updates and deletion.
The hardened tier, for a sensitive repository
This last tier targets projects whose compromise would be costly. It requires two approvals from different people to counter collusion, and signed commits to authenticate every author, with Gitsign (in French) for instance.
- Two-person review (SOCLE-SRC-BRA-4).
- Signed commits required by branch protection.
- Audit log of protection changes, reviewed regularly.
These tiers can be measured. The Branch-Protection check of OpenSSF Scorecard scores protection by sequential tiers: a missing approval of the most recent push alone blocks the 6/10 tier, whatever stricter settings are already active.
Which mistakes make the protection useless?
Most of these mistakes share one trait: a protection half enabled, which gives a false sense of security. The most frequent case is a CODEOWNERS file without a rule making it mandatory, so it can be ignored. Read the Consequence column before the fix: it says what an attacker can do as long as the setting is not corrected.
| Mistake | Consequence | Fix |
|---|---|---|
| CODEOWNERS without "Require review from Code Owners" | Owners are added but not mandatory | Enable the rule in the ruleset |
| Administrators in the bypass list | Every protection can be bypassed | Empty the bypass list |
| Unprotected tags | A published version changes content | Tag ruleset with Restrict updates and Restrict deletions |
* rule placed last | It overrides every specific rule | Default rule first |
CODEOWNERS with no owner for itself | A contributor removes the owners in their own PR | Assign .github/CODEOWNERS to a security team |
| Named team missing or without write access | Line ignored, review not required | Check the team and its rights |
| Bot token with broad write rights | One stolen token is enough to change anything | Minimally scoped, expiring, audited tokens |
Where does this configuration sit in SLSA Source?
SLSA (in French) has defined, since its version 1.2, a Source track in four levels. The table sums them up with their requirements; the right-hand column says what this lesson's configuration brings to each.
| Level | SLSA requirement | What this lesson brings |
|---|---|---|
| L1 Version controlled | A version control system | Covered by Git |
| L2 History and provenance | Continuous, immutable, retained history; provenance attestations issued by the forge | Protected history (force push and deletion forbidden), but no provenance attestation |
| L3 Continuous technical controls | The forge enforces the organisation's controls on named references | Branch and tag rulesets, required checks |
| L4 Two-party review | Two trusted people review every change to protected branches | Hardened tier, with two approvals |
The nuance matters: the configuration described here is aligned with the controls of levels 3 and 4, but it does not produce the source provenance attestations that level 2 already requires. On its own, it is therefore not enough to claim a SLSA Source level.
Key points
- Branch protection is the foundation: without it,
CODEOWNERSandCONTRIBUTING.mdare only suggestions. - On GitHub, prefer the ruleset to classic protection, and keep its bypass list empty.
- Approval of the most recent push stops anyone adding a commit after the review; without it, a review only covers what was actually read.
- Release tags need their own ruleset: the tj-actions attack rewrote them without touching a single branch.
CODEOWNERSdesignates the mandatory reviewers of sensitive areas, and must protect itself.- This configuration aligns with the SLSA Source L3 and L4 controls, without producing the provenance attestations required from L2.
- Evidence expected by SOCLE-SRC-GEN-1 (in French): the branch protection configuration, mandatory review and direct push forbidden. A screenshot or an export of the ruleset provides it.
Knowledge check
Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Living Off The Pipeline: when your DevOps tools become weapons
A new full quiz with random questions
Work again only on the questions you missed