Source Control & Branching
Git is the first security boundary in your delivery pipeline. Every commit is an immutable audit event; every branch policy is an access-control decision; every merge is a trust transfer from developer to production. This chapter maps threat → control → automation for workflows, branch protection, commit hygiene, code review, pre-commit hooks, and monorepo vs polyrepo trade-offs—with dual GitHub Actions and GitLab CI examples.
Git Workflows
Your branching model determines how fast you ship, how cleanly you audit changes, and how hard it is for an attacker to slip malicious code through a long-lived, unreviewed branch. Elite DevSecOps teams converge on trunk-based development with short-lived feature branches—not because GitFlow is wrong, but because integration delay is a security liability.
Threat model
Attackers and negligent insiders exploit workflow gaps: pushing directly to main, hiding changes in stale release branches, or merging without CI evidence. Long-lived branches diverge from production reality—security fixes on main never reach a six-month-old release/2.4 branch until a painful merge conflict invites shortcuts.
| Workflow | Branch lifetime | Security posture | Best for |
|---|---|---|---|
| Trunk-based | Feature branches < 2 days | Continuous integration; single source of truth; fast CVE patches | CI/CD mature teams, microservices, regulated SaaS |
| GitHub Flow | Short feature branches → main | PR-centric; deploy from main; environment branches optional | Web apps, continuous deployment |
| GitFlow | Long-lived develop + release branches | Higher merge complexity; delayed security backports | Versioned shrink-wrap software, mobile app stores |
| Release branches | Per-version maintenance lines | Cherry-pick discipline required; audit trail per release tag | LTS products, on-prem enterprise installs |
Solution: trunk-based with feature flags
Merge incomplete work behind feature flags instead of hoarding it on a branch. Every commit on main must pass CI; deploy pipelines read flag state at runtime. This collapses integration risk and ensures security scanners run on the code that actually ships.
flowchart LR
subgraph dev["Developer"]
F["feature/auth-mfa\n< 48h lifetime"]
end
subgraph trunk["Protected main"]
M["main\nalways green"]
end
subgraph cd["Continuous delivery"]
CI["CI + security gates"]
DEP["Deploy to staging"]
FF["Feature flag OFF"]
end
F -->|"PR + review"| M
M --> CI --> DEP
DEP --> FF
FF -->|"flag ON"| PROD["Production traffic"]
Platform automation: enforce short-lived branches
Stale branch cleanup and PR size limits reduce review fatigue—a social engineering vector where rushed reviewers approve oversized diffs. Automate reminders and block merges when branches exceed age or line-count thresholds.
name: Stale branch hygiene
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-branch-message: |
This branch has had no commits for 14 days.
Trunk-based policy: rebase or close. Security fixes land on main only.
days-before-branch-stale: 14
days-before-branch-delete: 7
ignore-branches: main,release/**
stale-branches:
stage: maintenance
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- |
curl --request DELETE \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/branches?search=feature/" \
| jq '.[] | select(.commit.committed_date < (now - 1209600)) | .name'
$ git switch main && git pull --ff-only $ git switch -c feature/pay-4821-mfa # small, focused change; flag guards incomplete UI $ git commit -m "feat(auth): add TOTP verify behind flag mfa_beta" $ git push -u origin feature/pay-4821-mfa $ gh pr create --base main --title "feat(auth): MFA TOTP (flagged)" → CI runs SAST/SCA before human review; never push directly to main
SolarWinds and xz-utils both exploited trust in the source tree—not git itself, but the absence of branch controls and maintainer verification. Trunk-based does not prevent insider threats; it ensures every change hits the same CI + review gates before reaching the branch attackers target for CI/CD pivot.
GitFlow's release branches simplify versioned hotfixes for installed software but multiply merge surfaces where CVE patches stall. Use release branches only when customers pin versions; otherwise trunk-based with semver tags from main is simpler and more secure.
When asked "GitFlow vs trunk-based," frame it as a risk and cadence question: deployment frequency, audit requirements, and whether you ship continuously or ship boxed versions. Mention feature flags as the enabler for trunk-based incomplete work.
Branch Protection
Branch protection is your preventive access control layer: it stops direct pushes, enforces CI status checks, requires human review, and optionally mandates signed commits. Without it, a stolen PAT or disgruntled insider can push malware to production in one command.
Threat model
- Credential compromise — leaked GITHUB_TOKEN or personal access token with write access
- Force-push history rewrite — attacker removes evidence of malicious commits
- Admin bypass — repo owners merging without review during "emergency"
- Unsigned commits — impersonation; no cryptographic proof of author identity
- Missing status checks — merge before SAST/secret scan completes
Solution: defense-in-depth rules
| Control | GitHub | GitLab | Why it matters |
|---|---|---|---|
| Block direct push | Branch protection rule on main | Protected branch + no direct push | Forces PR/MR path through CI and review |
| Required status checks | Required checks: ci/build, security/sast | Pipeline must succeed; security jobs non-optional | Prevents merge on failing security gates |
| Review count | ≥ 2 approvals; CODEOWNERS required | Approval rules + code owner approval | Segregation of duties; no self-merge |
| Signed commits | Require signed commits | Reject unsigned pushes | Non-repudiation; pairs with GPG/SSH signing |
| Linear history | Squash merge or rebase merge only | Fast-forward only optional | Cleaner audit trail; bisect-friendly |
| Include administrators | enforce_admins: true | No maintainer bypass | Executives cannot skip controls |
flowchart TD
PUSH["git push origin feature/x"] --> PR["Open pull request"]
PR --> CI["Required CI checks\nbuild · test · SAST · secrets"]
PR --> REV["≥ 2 approvals\nCODEOWNERS satisfied"]
PR --> SIG{"Signed commits?"}
SIG -->|no| BLOCK1["❌ Merge blocked"]
SIG -->|yes| GATE{"All gates green?"}
CI --> GATE
REV --> GATE
GATE -->|no| BLOCK2["❌ Merge blocked"]
GATE -->|yes| MERGE["✅ Merge to main"]
MERGE --> AUDIT["Immutable audit log\n+ provenance for CI"]
Infrastructure as code: branch rules
Manual UI configuration drifts across hundreds of repos. Encode branch protection in Terraform or a central policy repo so every new service inherits the same security baseline.
{
"pattern": "main",
"required_status_checks": {
"strict": true,
"contexts": ["ci/build", "security/sast", "security/gitleaks"]
},
"required_pull_request_reviews": {
"required_approving_review_count": 2,
"require_code_owner_reviews": true,
"dismiss_stale_reviews": true
},
"require_signed_commits": true,
"enforce_admins": true,
"required_linear_history": true,
"restrictions": null
}
branch_rules:
- name: main
branch_name: main
protected: true
push_access_level: no_one
merge_access_level: developer
allow_force_push: false
code_owner_approval_required: true
approvals_required: 2
status_checks:
- name: unit-tests
- name: sast
- name: secret_detection
commit_committer_check: true
reject_unsigned_commits: true
GitHub Organization Rulesets (API + Terraform github_repository_ruleset) apply branch protection across repo patterns—prefer org-level rulesets over per-repo drift. GitLab group-level push rules propagate to all projects under the group.
Allowing admins to bypass branch protection "for emergencies" creates a permanent backdoor. Define a break-glass procedure with post-incident review instead: temporary rule exemption with mandatory audit ticket and time-bound revert.
GitHub's merge queue serializes merges to main after CI passes on the PR head—preventing "green PR, red main" races when multiple teams merge simultaneously. Pair merge queue with required status checks for high-traffic monorepos.
Commit Standards
Commit messages are machine-readable metadata for changelogs, semver bumps, and security forensics. Conventional Commits plus signed commits turn every git log entry into a structured, verifiable audit event—not "fix stuff" buried in a 200-commit squash.
Threat model
Vague commits hide security-relevant changes: a dependency bump smuggled into "misc cleanup," or a credential rotation buried in a mega-commit. Unsigned commits allow author spoofing—git config user.email is trivially forgeable without GPG or SSH signing verification.
Conventional Commits format
| Type | Semver impact | Example |
|---|---|---|
| feat | MINOR bump | feat(auth): add WebAuthn registration |
| fix | PATCH bump | fix(api): validate JWT exp claim |
| security / fix! | PATCH or MAJOR | fix!: remove MD5 password hashing |
| chore | None (usually) | chore(deps): bump lodash 4.17.20 → 4.17.21 |
| BREAKING CHANGE: footer | MAJOR bump | Footer in body triggers major regardless of type |
Solution: enforce at commit and CI time
Local enforcement via commit-msg hooks catches mistakes before push. CI validates the PR title and squash commit message—release automation (semantic-release, release-please) depends on parseable history.
name: Commit standards
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
security
chore
docs
requireScope: true
- name: Verify signed commits
run: |
gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/commits \
--jq '.[] | select(.commit.verification.verified == false) | .sha' \
| tee /tmp/unsigned.txt
test ! -s /tmp/unsigned.txt
commitlint:
stage: validate
image: node:20-alpine
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
before_script:
- npm install -g @commitlint/cli @commitlint/config-conventional
script:
- |
npx commitlint --from="$CI_MERGE_REQUEST_DIFF_BASE_SHA" --to="$CI_COMMIT_SHA" --verbose
allow_failure: false
SSH commit signing setup
$ ssh-keygen -t ed25519 -C "[email protected]" -f ~/.ssh/signing_key -N "" $ git config --global gpg.format ssh $ git config --global user.signingkey ~/.ssh/signing_key.pub $ git config --global commit.gpgsign true # Upload ~/.ssh/signing_key.pub to GitHub → Settings → SSH keys → Signing key $ git commit -S -m "feat(payments): add idempotency key header" → GitHub shows "Verified" badge; branch protection can require it
SSH signing embeds a signature in the commit object alongside the tree and parent SHA. GitHub/GitLab verify against uploaded signing keys—not the author's email. GPG signing uses the same object model but with PGP keyrings; SSH signing is simpler for developers already using SSH for git transport.
Add a .gitmessage commit template with type/scope/body/footer sections. Run git config commit.template .gitmessage repo-wide via a setup script in the README—reduces malformed messages before hooks fire.
Squash-merge produces one commit per PR—clean history but loses per-commit granularity for bisect. Compromise: require conventional PR title as squash message; use merge commits for security-sensitive repos where per-commit signing audit matters.
Code Review
Automated scanners catch known patterns; humans catch intent, business logic flaws, and social engineering in dependency changes. Code review is the last human gate before code becomes CI input—treat it as a security control, not a style debate.
Threat model
- Self-approval — author merges own PR with a second account or admin rights
- Review fatigue — 2,000-line diffs approved in minutes; LGTM without reading
- CODEOWNERS bypass — missing owners file; security team never notified on auth changes
- Malicious dependency PR — typosquat package or lockfile manipulation hidden in generated files
- CI workflow injection — PR modifies .github/workflows/ to exfiltrate secrets
Solution: structured review with ownership
| Practice | Implementation | Security benefit |
|---|---|---|
| CODEOWNERS | .github/CODEOWNERS or GitLab CODEOWNERS | Auto-request security team on /auth/, /infra/ |
| PR size limits | Bot warning at > 400 LOC; split required at > 800 | Reviewers actually read the diff |
| Security checklist | PR template with authz, input validation, secrets items | Explicit attestation; audit evidence |
| Workflow path protection | CODEOWNERS on .github/** + .gitlab-ci.yml | CI pipeline changes need platform team eyes |
| SARIF in PR | Code scanning annotations inline | Reviewers see SAST findings in context |
# Default owners
* @acme/platform-team
# Security-sensitive paths — require security + domain owner
/src/auth/ @acme/security-team @acme/identity-squad
/src/payments/ @acme/security-team @acme/payments-squad
/infra/ @acme/platform-team @acme/security-team
/.github/workflows/ @acme/platform-team
/terraform/ @acme/platform-team @acme/security-team
# Dependencies — lockfile changes need explicit review
package-lock.json @acme/security-team @acme/frontend-leads
go.sum @acme/security-team @acme/backend-leads
sequenceDiagram participant Dev as Developer participant PR as Pull Request participant CI as CI Security participant CO as CODEOWNERS participant Sec as Security reviewer participant Main as protected main Dev->>PR: Open PR (+ template checklist) PR->>CI: Trigger SAST, SCA, gitleaks CI-->>PR: SARIF annotations inline PR->>CO: Request reviews auto-assigned CO->>Sec: Notify on /auth/ changes Sec->>PR: Approve with security sign-off PR->>Main: Merge (2 approvals + green CI)
PR template for security attestation
## Summary
<!-- What changed and why -->
## Security checklist
- [ ] No secrets, tokens, or PII in diff
- [ ] User input validated and parameterized (SQL, shell, path)
- [ ] AuthZ checked on new endpoints (not authN alone)
- [ ] Dependencies pinned; no unreviewed major bumps
- [ ] Threat model updated if attack surface changed
## Test evidence
- [ ] Unit tests added/updated
- [ ] SAST/SCA checks green
## Security review (required for ~security ~auth labels)
### Data classification
- [ ] No regulated data (PCI/PHI) in logs
- [ ] Encryption at rest/transit unchanged or improved
### Access control
- [ ] Least-privilege IAM / RBAC for new resources
- [ ] Service accounts scoped to single purpose
### Dependency changes
- [ ] Lockfile diff reviewed line-by-line
- [ ] No new unpinned git:// or http:// sources
/label ~security-review-required
Require two-person rule for workflow file changes: the engineer who needs the CI change cannot be the sole approver if they own the CODEOWNERS path. Platform team separation prevents CI pipeline hijacking—the attack vector behind many supply-chain incidents.
Describe code review as defense in depth with automation: "SAST catches SQLi patterns; human review catches authorization bugs where the query is safe but the user shouldn't see the row." Mention CODEOWNERS, PR size limits, and SARIF inline annotations as concrete controls.
Rubber-stamp reviews under deadline pressure. Mitigate with WIP limits, small PRs, and making review throughput a team metric—not just developer velocity. Security review is not free; staff it.
Pre-commit Hooks
Pre-commit hooks are the cheapest shift-left control: catch secrets, formatting violations, and private keys before they enter git history. Once a credential is committed, consider it rotated— git filter-repo does not erase fork copies or CI logs.
Threat model
Developers paste AWS keys into config files, commit .env by mistake, or add id_rsa during a hurried debug session. Bots scan GitHub within minutes of public exposure. Even private repos leak via misconfigured Actions logs, support tickets, or departed employee laptops.
Solution: layered local enforcement
| Hook type | Tool | Catches |
|---|---|---|
| Secret scan | gitleaks, detect-secrets | AWS keys, API tokens, private keys, JWTs |
| Commit message | commitlint | Non-conventional messages before push |
| Lint/format | ruff, eslint, prettier | Style + some security antipatterns (eval, etc.) |
| File guard | custom hook | Block *.pem, .env, large binaries |
| IaC scan | checkov (staged) | Public S3, open SG rules in staged Terraform |
pre-commit framework configuration
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
args: [--verbose, --redact]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: detect-private-key
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
- id: no-commit-to-branch
args: [--branch, main]
- repo: https://github.com/bridgecrewio/checkov
rev: 3.2.238
hooks:
- id: checkov
args: [--framework, terraform, --quiet]
- repo: https://github.com/commitizen-tools/commitizen
rev: v3.27.0
hooks:
- id: commitizen
stages: [commit-msg]
$ pip install pre-commit $ pre-commit install pre-commit installed at .git/hooks/pre-commit $ pre-commit install --hook-type commit-msg $ pre-commit run --all-files gitleaks.............................................................Passed $ git commit -m "feat(api): add webhook handler" → hooks run on staged files only; fast feedback < 10s typical
CI backstop: hooks can be skipped
git commit --no-verify bypasses local hooks. CI must re-run the same checks. Mirror pre-commit config in the pipeline for deterministic, auditable enforcement.
name: Pre-commit CI
on: [pull_request, push]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
include:
- template: Security/Secret-Detection.gitlab-ci.yml
pre-commit:
stage: test
image: python:3.12-slim
script:
- pip install pre-commit
- pre-commit run --all-files --show-diff-on-failure
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
secret_detection:
stage: test
variables:
SECRET_DETECTION_HISTORIC_SCAN: "true"
pre-commit creates isolated tool environments per hook—no global pollution. gitleaks scans staged diffs with regex + entropy rules; --redact masks findings in output so terminal scrollback does not become a new leak vector.
Run historic scans weekly on full git history—pre-commit only sees new commits; legacy leaks hide in old branches. Pair gitleaks with detect-secrets baselines for false positive management, but never baseline real credentials—rotate and remove.
Heavy hooks (Checkov on every commit) slow developers > 30s → they use --no-verify. Run expensive scans on pre-push or CI only; keep pre-commit under 10 seconds with fast rules (secrets, format, private keys).
Monorepo vs Polyrepo
Repository topology is a security architecture decision: blast radius, access boundaries, dependency trust, and CI credential scope all shift with monorepo vs polyrepo. Neither is universally correct—mature teams choose based on trust zones and change coupling, not fashion.
Threat model
- Blast radius expansion — one compromised CI token in a monorepo builds every service
- Dependency confusion — polyrepo internal packages published without namespace controls
- Inconsistent security baselines — polyrepo repos missing branch protection or pre-commit
- Shadow dependencies — monorepo teams import across boundaries without CODEOWNERS review
- Over-broad access — all engineers clone entire monorepo including regulated components
Comparison matrix
| Dimension | Monorepo | Polyrepo |
|---|---|---|
| Atomic cross-service changes | Single PR updates API + client + IaC | Coordinated PRs across repos; version pinning lag |
| CI credential scope | Wide—path filters and OIDC claims must narrow | Per-repo least privilege by default |
| Security policy consistency | One CODEOWNERS, one pre-commit config | Drift without org-level rulesets |
| Build time at scale | Needs affected-detection (Nx, Bazel) | Independent pipelines; simpler per repo |
| Insider threat isolation | Harder—clone exposes all code | Repo-level access controls feasible |
| Supply chain audit | Single SBOM scope; clearer provenance | Many artifacts; aggregation required |
flowchart TB
subgraph mono["Monorepo push"]
PUSH["push to main"]
PATH["path filter:\nservices/payments/**"]
PAY["payments CI only"]
SKIP["skip 40 other services"]
end
PUSH --> PATH
PATH --> PAY
PATH --> SKIP
subgraph poly["Polyrepo push"]
P2["push payments-service repo"]
P2CI["payments pipeline only\nnarrow OIDC role"]
end
P2 --> P2CI
Solution: path-filtered CI with OIDC scoping
Monorepos require affected builds and path filters so a docs change does not trigger production deploys for unrelated services. OIDC trust policies should include path-based subject claims where the platform supports it.
name: payments-service
on:
pull_request:
paths:
- "services/payments/**"
- "libs/payments-sdk/**"
- ".github/workflows/payments-service.yml"
push:
branches: [main]
paths:
- "services/payments/**"
- "libs/payments-sdk/**"
permissions:
contents: read
id-token: write
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
payments:
- "services/payments/**"
- "libs/payments-sdk/**"
- if: steps.filter.outputs.payments == 'true'
run: echo "Build payments only — OIDC role arn:aws:iam::123:role/gh-payments"
payments-build:
stage: build
rules:
- changes:
- services/payments/**/*
- libs/payments-sdk/**/*
script:
- cd services/payments && npm ci && npm test && npm run build
auth-build:
stage: build
rules:
- changes:
- services/auth/**/*
script:
- cd services/auth && go test ./...
# Polyrepo alternative: separate GitLab projects with shared CI template
include:
- project: platform/ci-templates
file: /security-pipeline.yml
Polyrepo: org-level policy consistency
Without centralized templates, polyrepo sprawl means repo #47 still lacks signed commits. Use golden pipeline templates, org rulesets, and internal Terraform modules for repo creation that apply branch protection, pre-commit, and CODEOWNERS scaffolding automatically.
$ npx nx affected -t test,lint,build --base=origin/main NX Running targets test, lint, build for 2 projects: - payments-api - payments-sdk $ npx nx graph --affected → CI runs only impacted projects; security scans scoped to affected artifacts
Google and Meta operate massive monorepos with custom tooling (Bazel, Sapling). Netflix uses many repos with shared Spinnaker pipelines. Choose monorepo when cross-team atomic changes dominate; choose polyrepo when regulatory isolation or independent release cadences are mandatory.
Provision new polyrepo services via Terraform github_repository module that applies branch protection, adds team access, and seeds .pre-commit-config.yaml from a template—security baseline on day zero, not ticket #400 six months later.
Monorepos simplify consistent controls but concentrate CI secrets—one malicious workflow file affects all services. Polyrepos isolate blast radius but multiply policy drift. Hybrid: monorepo for application code, separate locked-down repos for production IaC and shared secrets infrastructure.
Learning path from here: CI Pipelines → Testing Strategy → Security Scanning. Source control gates mean nothing if CI does not enforce the same checks on every merge.