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.

developer devops security Git 2.40+ SLSA Source

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.

.github/workflows/stale-branches.yml
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/**
.gitlab-ci.yml — branch hygiene job
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'
terminal — trunk-based daily flow
$ 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
🔒 Security

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.

⚖️ Trade-off

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.

🎯 Interview Tip

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.

terraform — github_branch_protection (excerpt)
{
  "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
}
.gitlab/branch-rules.yml (GitLab 17+)
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
🏗 IaC

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.

⚠️ Pitfall

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.

📦 Real World

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.

.github/workflows/commit-lint.yml
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
.gitlab-ci.yml — commit lint job
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

terminal — SSH-signed commits (Git 2.34+)
$ 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
🔬 Under the Hood

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.

💡 Pro Tip

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.

⚖️ Trade-off

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
.github/CODEOWNERS
# 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

.github/pull_request_template.md
## 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
.gitlab/merge_request_templates/Security.md
## 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
🔒 Security

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.

🎯 Interview Tip

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.

⚠️ Pitfall

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

.pre-commit-config.yaml
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]
terminal — developer setup
$ 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.

.github/workflows/pre-commit-ci.yml
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 }}
.gitlab-ci.yml — pre-commit + secret detection
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"
🔬 Under the Hood

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.

🔒 Security

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.

⚖️ Trade-off

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.

.github/workflows/payments-service.yml
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"
.gitlab-ci.yml — monorepo rules
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.

terminal — Nx affected (monorepo CI optimization)
$ 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
📦 Real World

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.

🏗 IaC

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.

⚖️ Trade-off

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.

💡 Pro Tip

Learning path from here: CI PipelinesTesting StrategySecurity Scanning. Source control gates mean nothing if CI does not enforce the same checks on every merge.