Securing the source
Branch protection, signed commits, least privilege.
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.
{"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.
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.
# 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'
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.
# try what the rule forbids: push straight to main$ echo "x" >> README.md$ git commit -am "quick fix"$ git push origin main
[main 9f3a1c2] quick fix1 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 0remote: 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.
# 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>'
[main 4c1e9a2] add debug endpointauthor: 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.
# 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"
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.
$ git verify-commit HEAD
tlog index: 74129983gitsign: Signature made using certificate ID 0x9f2c... | CN=sigstore-intermediate,O=sigstore.devgitsign: Good signature from [[email protected]](https://accounts.google.com)Validated Git signature: trueValidated Rekor entry: trueValidated 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.
# 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).
# 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'
priya-n maintaindeploy-bot writesam-ops adminoldintern 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.
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?/.gitlab-ci.yml routes the change to platform-security, and the attacker cannot produce that approval from the account they stole.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?git log still prints them.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?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.