Securing the source

Branch protection, signed commits, least privilege.

Advanced12 min · lesson 4 of 18

Every image you ship and every deploy you run traces back to one place: the commit that landed on your main branch. If someone can slip a line of code in there without being seen, nothing downstream saves you. Your scanners, your signed artifacts, the admission controller in your cluster, they all trust what the source told them. So the repository is where you spend your first and best locks, and it is where an attacker spends their first effort too.

The receiving desk for your main branch

A busy kitchen doesn't let delivery drivers carry crates straight to the stove. Everything stops at a receiving desk, someone checks the delivery against the order, and only approved goods go through. Branch protection is that desk for your default branch. Direct pushes are turned off, so the only way a change reaches main is through a merge request (a proposed change, also called a pull request, that has to be reviewed before it becomes part of the branch). A reviewer signs off, the automated checks come back green, and only then does the merge happen.

On GitHub the modern way to write these rules down is a repository ruleset. You write it as a JSON file, keep that file in version control so a change to the rules gets reviewed like any other code change, and push it to the repo through GitHub's API. Here is one that covers the essentials.

ruleset.json
{
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_signatures" },
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"require_code_owner_review": true,
"dismiss_stale_reviews_on_push": true,
"require_last_push_approval": true
}
},
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "scan/sast" },
{ "context": "scan/deps" },
{ "context": "build" }
]
}
}
],
"bypass_actors": []
}

Read that top to bottom. The deletion and non_fast_forward rules stop anyone deleting the branch or rewriting its history. We come back to required_signatures in a moment. The pull_request rule demands one approval, and because require_code_owner_review is on, that approval has to come from the right person for the files being touched. dismiss_stale_reviews_on_push throws away an approval the instant new commits arrive, which closes a nasty trick: get a reviewer to approve a clean diff, then quietly push a poisoned commit onto the same request. require_last_push_approval means whoever pushed last cannot be the one who approves it. The status-check rule blocks the merge until your scanners and build pass, and the strict policy forces the branch to be up to date with the latest main before it can merge, so a green check earned on stale code can't sneak through.

The bypass_actors list is empty on purpose, and that matters more than it looks.

Rules you forgot to apply to yourself
Branch protection only protects the branch from people the rule actually covers. On GitHub, anyone in bypass_actors (and, with classic protection, admins whenever enforce_admins is off) walks straight past every rule above. That is the first thing an attacker who lands an admin account goes looking for. Keep the list empty and check it on a schedule: gh api repos/acme/widgets/rulesets/RULESET_ID --jq '.bypass_actors' should print [] and nothing else.

Apply it and confirm it took effect.

terminal
# apply the ruleset from a file you keep in version control
$ gh api -X POST repos/acme/widgets/rulesets \
--input ruleset.json \
--jq '.name + " -> " + .enforcement'
output
protect-main -> active

Now prove the door is actually locked. The best test of a control is to try the thing it forbids and watch it fail.

terminal
# try what the rule forbids: push straight to main
$ echo "x" >> README.md
$ git commit -am "quick fix"
$ git push origin main
output
[main 9f3a1c2] quick fix
1 file changed, 1 insertion(+)
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote:
remote: - Changes must be made through a pull request.
remote:
To github.com:acme/widgets.git
! [remote rejected] main -> main (push declined due to repository rule violations)
error: failed to push some refs to 'github.com:acme/widgets.git'

That GH013 rejection is exactly what a defender wants to see. If the push had gone through, every later control in this course would be standing on sand, because the reviewed-source assumption they all depend on would already be broken at the root.

Sign your commits so the name can't be faked

A commit's author field is like the 'From' line you write on an envelope. You can put any name there. Git will happily record a commit as coming from your most senior engineer, because that field is plain text and nobody checks it.

terminal
# the author is plain text; anyone can type anyone's name
$ git -c user.name="Priya Nadella" -c user.email="[email protected]" \
commit --allow-empty -m "add debug endpoint"
$ git log -1 --format='author: %an <%ae>'
output
[main 4c1e9a2] add debug endpoint
author: Priya Nadella <[email protected]>

There is the forgery, in two commands. The fix is the same one letters have used for centuries: a seal that is hard to fake and easy to check. Commit signing attaches a cryptographic signature to the commit, and the platform shows a 'verified' badge once it checks that seal against a key it trusts. You can sign with GPG (GNU Privacy Guard, a long-standing tool for signing and encrypting), with an SSH key (Secure Shell, the same kind of key you already use to log in to servers), or with Sigstore's gitsign, which is worth knowing because it uses no long-lived key at all.

gitsign proves your identity through OIDC (OpenID Connect, a standard way to prove 'I am this account' using a short-lived token from an identity provider like Google or your company login), gets a certificate that lives about ten minutes from Sigstore's Fulcio service, signs the commit with it, and records the signature in Rekor (a public, append-only log, so a signature can be checked later and can't be quietly removed). No key file sitting on a laptop for someone to steal.

terminal
# keyless signing with gitsign (Sigstore)
$ git config gpg.x509.program gitsign
$ git config gpg.format x509
$ git config commit.gpgsign true
$ git commit --allow-empty -m "add debug endpoint"
output
Your browser will now be opened to:
https://oauth2.sigstore.dev/auth?client_id=sigstore&code_challenge=...
[main 7b2d5f0] add debug endpoint

Checking a signature is one command, and it reports three separate things.

terminal
$ git verify-commit HEAD
output
tlog index: 74129983
gitsign: Signature made using certificate ID 0x9f2c... | CN=sigstore-intermediate,O=sigstore.dev
gitsign: Good signature from [[email protected]](https://accounts.google.com)
Validated Git signature: true
Validated Rekor entry: true
Validated certificate claims: true

Read the last three lines. The signature matches the commit, the Rekor log agrees the entry exists, and the certificate really was issued to [email protected]. Faking the author line took two commands. Faking this takes Priya's actual identity-provider login. One caveat is worth knowing before you rely on it. GitHub's own 'Verified' badge, and the required_signatures rule that leans on it, only recognise GPG or SSH keys that people have registered with their accounts. GitHub does not yet validate Sigstore certificates, so a gitsign commit shows up there as 'Unverified'. That gives you two ways to enforce signing, and you pick the one that matches how you sign. If your developers sign with registered GPG or SSH keys, required_signatures rejects any unsigned commit at the branch. If they sign with gitsign, you add a required status check that runs git verify-commit over the pushed commits and fails the merge when a signature doesn't hold up. Either way, an attacker with plain write access can't push history that carries a trusted developer's verified signature.

Guard the build files hardest

In a building, a tenant repainting their own wall is one thing. Someone rewiring the electrical panel is another, and you want the licensed inspector to sign that off, not whoever happens to be nearby. Your repository has an electrical panel: the pipeline definition, the deploy manifests, and the Dockerfile. A change to any of those alters how everything is built and shipped without touching a line of application code. An attacker who can quietly edit .gitlab-ci.yml or the Dockerfile owns your build.

CODEOWNERS is how you name the inspector. It maps file paths to the people or teams who must review changes there, and the code-owner rule in your ruleset turns that review from a suggestion into a requirement.

/CODEOWNERS
# fallback: any change needs a maintainer
* @acme/maintainers
# high-value build & deploy files need platform-security
/.gitlab-ci.yml @acme/platform-security
/.github/workflows/ @acme/platform-security
/Dockerfile @acme/platform-security
# deploy changes need security AND the on-call operators
/deploy/ @acme/platform-security @acme/sre

Now a README typo needs any maintainer, a line in the pipeline needs platform-security, and a deploy change needs both platform-security and the on-call operators. The scrutiny follows the blast radius.

Least privilege on the repository itself

The repository is production infrastructure. Treat an account with write access to it the way you treat a login on the deploy server, because in effect that is what it is.

Turn on MFA (multi-factor authentication, a second proof of identity on top of the password, like a code from an app or a tap on a hardware key) for every account, with no exceptions. A phished developer password with no second factor is a supply-chain compromise that simply hasn't been noticed yet.

Scope automation like a hotel keycard: one door, and it expires. A deploy key or personal access token (a string that authenticates a script or bot instead of a human) should carry the least it needs, read-only if it only pulls, and it should have a rotation date. Never hand a pipeline a token that can edit branch protection or push to main, or the build gains the power to open its own front door.

Review who can actually write, on a schedule, straight from the API (the programmatic interface GitHub exposes, so you read the live state instead of trusting your memory).

terminal
# who can write to or administer this repo?
$ gh api repos/acme/widgets/collaborators --paginate \
--jq '.[] | select(.permissions.push or .permissions.admin)
| [.login, .role_name] | @tsv'
output
priya-n maintain
deploy-bot write
sam-ops admin
oldintern write

There it is: oldintern still has write months after the internship ended. That is the account that gets phished and nobody misses. Remove it, rotate any token you can't account for, and pull admin down to the smallest set of humans who genuinely need it.

How a change earns its way onto main
1Signed commit
author identity bound by a key, not typed in
2Open a merge request
direct pushes to main are turned off
3Checks pass
scanners and build green, against latest main
4Code-owner review
the right approver for the files touched
5Merge to protected main
the only way in; history can't be rewritten
Quick check
01An attacker phishes one developer's account and now has write access. Your main branch ruleset requires signatures, one approval, require_code_owner_review and status checks, and /.gitlab-ci.yml is owned by @acme/platform-security. Which rule most directly blocks a quiet pipeline edit?
Incorrect — Signing tells you which identity stands behind a commit, and that identity is exactly the one the attacker took over. At its best it proves authorship, it never adds a second reviewer.
Incorrect — That setting stops a green result earned on stale code. A poisoned pipeline edit can sit on a perfectly up to date branch and still pass every scanner you run.
Correct — Ownership on /.gitlab-ci.yml routes the change to platform-security, and the attacker cannot produce that approval from the account they stole.
Incorrect — That rule defends a review that already happened, which shuts down the approve-then-poison trick. By itself it never forces anyone to review the change in the first place.
02The lesson forges a commit with git -c user.name="Priya Nadella" and git log -1 duly reports her as the author. Why does commit signing stop that trick when the author field on its own cannot?
Correct — Faking the name took one command with no secret in it. Producing a signature that verifies takes Priya's registered key or her live identity-provider session, which plain write access does not hand you.
Incorrect — A signature seals, it does not conceal. The message and the author line stay in plain view after signing, which is why git log still prints them.
Incorrect — Git already pins a commit's contents to its hash. The problem here is that the forger typed someone else's name at commit time, and freezing a lie in place does not help.
Incorrect — The badge reports the result of a signature check and sits alongside the name rather than replacing it. Leave a commit unsigned and reviewers still see whatever name was typed.
03A scheduled gh api repos/acme/widgets/collaborators run prints priya-n maintain, deploy-bot write, sam-ops admin, oldintern write. Which follow-up fits least privilege best?
Incorrect — MFA belongs on every account here, but it does not retire access that should have ended months ago. A second factor on a dormant intern login still leaves that login able to write.
Incorrect — A build that can edit branch protection can also disable it. Keep the bot's token narrow, read-only where it only pulls, and let a person apply ruleset changes.
Incorrect — Write on this repository is a login on the deploy path. Anything that can get code onto main can get code into production, admin or not.
Correct — A dormant account with live write access is the one nobody misses when it gets phished. Clearing it, rotating unexplained tokens and shrinking admin is what the audit output is asking for.

One habit ties all of this together. From an account you believe is fully privileged, try to push straight to main, and try to approve your own merge request. If either one works, your receiving desk has a side door, and it is far better that you find it on a Tuesday afternoon than that an attacker finds it first.

Try this

Run echo "x" >> README.md on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: rules you forgot to apply to yourself. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related