diff --git a/standards/dependabot-policy.md b/standards/dependabot-policy.md new file mode 100644 index 0000000..702889b --- /dev/null +++ b/standards/dependabot-policy.md @@ -0,0 +1,176 @@ +# Dependabot Policy: Security-Only Updates + +## Rationale + +New package versions pose risk to stability and security. Keeping known-good versions +and upgrading only when vulnerabilities are found and fixed provides a better +security posture than chasing every minor/patch release. + +## Policy + +1. **Security updates only** for application dependencies (npm, Go modules, pip, Cargo). + Dependabot opens PRs only when a vulnerability advisory exists for a dependency. + Version update PRs are suppressed by setting `open-pull-requests-limit: 0` for + application ecosystems — Dependabot security updates bypass this limit. +2. **Version updates weekly** for GitHub Actions, since pinned action versions do not + affect application stability and staying current reduces CI attack surface. +3. **Labels** `security` and `dependencies` on every Dependabot PR for filtering and audit. +4. **Auto-merge** security patches and minor updates after all CI checks pass, using a + GitHub App token to satisfy branch protection (CODEOWNERS review bypass for bot PRs). + Uses `gh pr merge --auto` to wait for required checks before merging. +5. **Vulnerability audit CI check** runs on every PR and push to `main`, failing the + build if any dependency has a known advisory. This is a required status check. + +## Prerequisites + +- **Dependabot security updates** must be enabled at the org or repo level + (Settings > Code security > Dependabot security updates). +- The `dependabot.yml` entries below configure which ecosystems and directories + Dependabot monitors. Setting `open-pull-requests-limit: 0` for application + ecosystems suppresses routine version-update PRs while still allowing + security-alert-triggered PRs to be created. + +## Configuration Files + +Each repository must have: + +| File | Purpose | +|------|---------| +| `.github/dependabot.yml` | Dependabot config scoped to the repo's ecosystems | +| `.github/workflows/dependabot-automerge.yml` | Auto-approve + squash-merge security PRs | +| `.github/workflows/dependency-audit.yml` | CI check — fail on known vulnerabilities | + +## Dependabot Templates + +Use the template matching your repository type. + +**Application ecosystems** (npm, gomod, cargo, pip, terraform) use: + +```yaml +schedule: + interval: "weekly" +open-pull-requests-limit: 0 # suppress version updates; security PRs bypass this +labels: + - "security" + - "dependencies" +``` + +**GitHub Actions** uses: + +```yaml +schedule: + interval: "weekly" +open-pull-requests-limit: 10 # allow version updates for CI actions +labels: + - "security" + - "dependencies" +``` + +### Frontend (npm) + +For repos with `package.json` at root or in subdirectories. + +- Ecosystem: `npm` +- Directory: `/` (or path to each `package.json`) + +See [`dependabot/frontend.yml`](dependabot/frontend.yml) + +### Backend — Go + +For repos with `go.mod`. + +- Ecosystem: `gomod` +- Directory: path to `go.mod` + +See [`dependabot/backend-go.yml`](dependabot/backend-go.yml) + +### Backend — Rust + +For repos with `Cargo.toml`. + +- Ecosystem: `cargo` +- Directory: `/` + +See [`dependabot/backend-rust.yml`](dependabot/backend-rust.yml) + +### Backend — Python + +For repos with `pyproject.toml` or `requirements.txt`. + +- Ecosystem: `pip` +- Directory: `/` + +See [`dependabot/backend-python.yml`](dependabot/backend-python.yml) + +### Infrastructure — Terraform + +For repos with Terraform modules. + +- Ecosystem: `terraform` +- Directory: path to Terraform root module + +See [`dependabot/infra-terraform.yml`](dependabot/infra-terraform.yml) + +### GitHub Actions (all repos) + +Every repository must include the `github-actions` ecosystem entry. +GitHub Actions use **version updates** (not security-only) on a weekly schedule +since pinned action SHAs do not affect application runtime stability. + +```yaml +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" +``` + +### Full-Stack Example + +A full-stack repo (e.g., npm + Go + Terraform + GitHub Actions) combines the +relevant ecosystem entries into a single `dependabot.yml`. See +[`dependabot/fullstack.yml`](dependabot/fullstack.yml) for a complete example. + +## Auto-Merge Workflow + +See [`workflows/dependabot-automerge.yml`](workflows/dependabot-automerge.yml). + +Behavior: +- Triggers on `pull_request_target` from `dependabot[bot]` +- Fetches Dependabot metadata to determine update type +- For **patch** and **minor** updates (and indirect dependency updates): + approves the PR and enables auto-merge (waits for all required CI checks) +- **Major** updates are left for human review +- Uses `gh pr merge --auto --squash` so the merge only happens after CI passes + +## Vulnerability Audit CI Check + +See [`workflows/dependency-audit.yml`](workflows/dependency-audit.yml). + +This workflow template detects the ecosystems present in the repo and runs the +appropriate audit tool: + +| Ecosystem | Tool | Command | +|-----------|------|---------| +| npm | `npm audit` | `npm audit --audit-level=low` per `package-lock.json` (fails on any advisory) | +| pnpm | `pnpm audit` | `pnpm audit --audit-level low` per `pnpm-lock.yaml` | +| Go | `govulncheck` | `govulncheck ./...` per `go.mod` directory | +| Rust | `cargo-audit` | `cargo audit` per `Cargo.toml` workspace | +| Python | `pip-audit` | `pip-audit .` per `pyproject.toml` / `-r requirements.txt` | + +The workflow fails if any known vulnerability is found, blocking the PR from merging. + +## Applying to a Repository + +1. Copy the appropriate `dependabot.yml` template to `.github/dependabot.yml`, + adjusting `directory` paths as needed. +2. Add `workflows/dependabot-automerge.yml` to `.github/workflows/`. +3. Add `workflows/dependency-audit.yml` to `.github/workflows/`. +4. Ensure the repository has the GitHub App secrets (`APP_ID`, `APP_PRIVATE_KEY`) + configured for auto-merge. +5. Create the `security` and `dependencies` labels in the repository if they + don't already exist. +6. Add `dependency-audit` as a required status check in branch protection rules. diff --git a/standards/dependabot/backend-go.yml b/standards/dependabot/backend-go.yml new file mode 100644 index 0000000..985d672 --- /dev/null +++ b/standards/dependabot/backend-go.yml @@ -0,0 +1,26 @@ +# Dependabot configuration for Go backend repositories +# Copy to .github/dependabot.yml and adjust directory paths as needed +# +# gomod uses open-pull-requests-limit: 0 to suppress version update PRs. +# Dependabot security updates bypass this limit, so security PRs still open. +version: 2 +updates: + # Go modules — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # GitHub Actions — version updates (keep actions current) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/standards/dependabot/backend-python.yml b/standards/dependabot/backend-python.yml new file mode 100644 index 0000000..dc8ff3f --- /dev/null +++ b/standards/dependabot/backend-python.yml @@ -0,0 +1,26 @@ +# Dependabot configuration for Python backend repositories +# Copy to .github/dependabot.yml and adjust directory paths as needed +# +# pip uses open-pull-requests-limit: 0 to suppress version update PRs. +# Dependabot security updates bypass this limit, so security PRs still open. +version: 2 +updates: + # pip — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # GitHub Actions — version updates (keep actions current) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/standards/dependabot/backend-rust.yml b/standards/dependabot/backend-rust.yml new file mode 100644 index 0000000..5570ac8 --- /dev/null +++ b/standards/dependabot/backend-rust.yml @@ -0,0 +1,26 @@ +# Dependabot configuration for Rust backend repositories +# Copy to .github/dependabot.yml and adjust directory paths as needed +# +# cargo uses open-pull-requests-limit: 0 to suppress version update PRs. +# Dependabot security updates bypass this limit, so security PRs still open. +version: 2 +updates: + # Cargo — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # GitHub Actions — version updates (keep actions current) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/standards/dependabot/frontend.yml b/standards/dependabot/frontend.yml new file mode 100644 index 0000000..7398cb4 --- /dev/null +++ b/standards/dependabot/frontend.yml @@ -0,0 +1,26 @@ +# Dependabot configuration for frontend (npm) repositories +# Copy to .github/dependabot.yml and adjust directory paths as needed +# +# npm uses open-pull-requests-limit: 0 to suppress version update PRs. +# Dependabot security updates bypass this limit, so security PRs still open. +version: 2 +updates: + # npm — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # GitHub Actions — version updates (keep actions current) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/standards/dependabot/fullstack.yml b/standards/dependabot/fullstack.yml new file mode 100644 index 0000000..fb1a045 --- /dev/null +++ b/standards/dependabot/fullstack.yml @@ -0,0 +1,51 @@ +# Dependabot configuration for full-stack repositories (npm + Go + Terraform) +# Copy to .github/dependabot.yml and adjust directory paths as needed +# +# Application ecosystems use open-pull-requests-limit: 0 to suppress version +# update PRs. Dependabot security updates bypass this limit. +# +# Example layout: +# apps/mobile/package.json (or markets-app/package.json) +# apps/api/go.mod (or markets-api/go.mod) +# infra/terraform/main.tf +version: 2 +updates: + # npm — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # Go modules — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "gomod" + directory: "/apps/api" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # Terraform — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "terraform" + directory: "/infra/terraform" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # GitHub Actions — version updates (keep actions current) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/standards/dependabot/infra-terraform.yml b/standards/dependabot/infra-terraform.yml new file mode 100644 index 0000000..e6e27c4 --- /dev/null +++ b/standards/dependabot/infra-terraform.yml @@ -0,0 +1,26 @@ +# Dependabot configuration for Terraform infrastructure repositories +# Copy to .github/dependabot.yml and adjust directory paths as needed +# +# terraform uses open-pull-requests-limit: 0 to suppress version update PRs. +# Dependabot security updates bypass this limit, so security PRs still open. +version: 2 +updates: + # Terraform — security updates only (limit 0 suppresses version updates) + - package-ecosystem: "terraform" + directory: "/infra/terraform" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + # GitHub Actions — version updates (keep actions current) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/standards/workflows/dependabot-automerge.yml b/standards/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..6080c9a --- /dev/null +++ b/standards/workflows/dependabot-automerge.yml @@ -0,0 +1,78 @@ +# Dependabot auto-merge workflow +# Copy to .github/workflows/dependabot-automerge.yml +# +# Requires repository secrets: +# APP_ID — GitHub App ID with contents:write and pull-requests:write +# APP_PRIVATE_KEY — GitHub App private key +# +# Auto-approves and enables auto-merge for Dependabot PRs that are: +# - GitHub Actions updates (patch or minor version bumps) +# - Security updates for any ecosystem (patch or minor) +# - Indirect (transitive) dependency updates +# Major version updates are always left for human review. +# Uses --auto so the merge waits for all required CI checks to pass. +# +# Safety model: application ecosystems use open-pull-requests-limit: 0 in +# dependabot.yml, so the only app-ecosystem PRs Dependabot can create are +# security updates. This workflow adds defense-in-depth by also checking +# the package ecosystem. +name: Dependabot auto-merge + +on: + pull_request_target: + branches: + - main + +permissions: {} + +jobs: + dependabot: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Determine if auto-merge eligible + id: eligible + run: | + UPDATE_TYPE="${{ steps.metadata.outputs.update-type }}" + DEP_TYPE="${{ steps.metadata.outputs.dependency-type }}" + ECOSYSTEM="${{ steps.metadata.outputs.package-ecosystem }}" + + # Must be patch, minor, or indirect + if [[ "$UPDATE_TYPE" != "version-update:semver-patch" && \ + "$UPDATE_TYPE" != "version-update:semver-minor" && \ + "$DEP_TYPE" != "indirect" ]]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + echo "Skipping: major update requires human review" + exit 0 + fi + + # GitHub Actions version updates are always eligible + # App ecosystem PRs can only exist as security updates (limit: 0) + echo "eligible=true" >> "$GITHUB_OUTPUT" + echo "Auto-merge eligible: ecosystem=$ECOSYSTEM update=$UPDATE_TYPE" + + - name: Generate app token + if: steps.eligible.outputs.eligible == 'true' + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Approve and enable auto-merge + if: steps.eligible.outputs.eligible == 'true' + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/standards/workflows/dependency-audit.yml b/standards/workflows/dependency-audit.yml new file mode 100644 index 0000000..5a1c53b --- /dev/null +++ b/standards/workflows/dependency-audit.yml @@ -0,0 +1,217 @@ +# Dependency vulnerability audit +# Copy to .github/workflows/dependency-audit.yml +# +# Auto-detects ecosystems present in the repository and runs the appropriate +# audit tool. Fails the build if any dependency has a known security advisory. +# +# Add "dependency-audit" as a required status check in branch protection. +# +# Pinned tool versions (update deliberately): +# govulncheck v1.1.4 | cargo-audit 0.22.1 | pip-audit 2.9.0 +name: Dependency audit + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + detect: + name: Detect ecosystems + runs-on: ubuntu-latest + outputs: + npm: ${{ steps.check.outputs.npm }} + pnpm: ${{ steps.check.outputs.pnpm }} + gomod: ${{ steps.check.outputs.gomod }} + cargo: ${{ steps.check.outputs.cargo }} + pip: ${{ steps.check.outputs.pip }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Detect package ecosystems + id: check + run: | + # npm — look for package-lock.json anywhere (excluding node_modules) + if find . -name 'package-lock.json' -not -path '*/node_modules/*' | grep -q .; then + echo "npm=true" >> "$GITHUB_OUTPUT" + else + echo "npm=false" >> "$GITHUB_OUTPUT" + fi + + # pnpm — look for pnpm-lock.yaml anywhere + if find . -name 'pnpm-lock.yaml' -not -path '*/node_modules/*' | grep -q .; then + echo "pnpm=true" >> "$GITHUB_OUTPUT" + else + echo "pnpm=false" >> "$GITHUB_OUTPUT" + fi + + # Go modules — detect via go.mod (not go.sum, which may not exist) + if find . -name 'go.mod' -not -path '*/vendor/*' | grep -q .; then + echo "gomod=true" >> "$GITHUB_OUTPUT" + else + echo "gomod=false" >> "$GITHUB_OUTPUT" + fi + + # Cargo — detect via Cargo.toml anywhere (lockfile may not exist for libraries) + if find . -name 'Cargo.toml' -not -path '*/target/*' | grep -q .; then + echo "cargo=true" >> "$GITHUB_OUTPUT" + else + echo "cargo=false" >> "$GITHUB_OUTPUT" + fi + + # Python — detect pyproject.toml or requirements.txt anywhere + if find . -name 'pyproject.toml' -not -path '*/.venv/*' -not -path '*/venv/*' | grep -q . || \ + find . -name 'requirements.txt' -not -path '*/.venv/*' -not -path '*/venv/*' | grep -q .; then + echo "pip=true" >> "$GITHUB_OUTPUT" + else + echo "pip=false" >> "$GITHUB_OUTPUT" + fi + + audit-npm: + name: npm audit + needs: detect + if: needs.detect.outputs.npm == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "lts/*" + + - name: Audit npm dependencies + run: | + # Audit each package-lock.json found in the repo + status=0 + while IFS= read -r dir; do + echo "::group::npm audit $dir" + if ! (cd "$dir" && npm audit --audit-level=low); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'package-lock.json' -not -path '*/node_modules/*' -exec dirname {} \;) + exit $status + + audit-pnpm: + name: pnpm audit + needs: detect + if: needs.detect.outputs.pnpm == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "lts/*" + + - name: Audit pnpm dependencies + run: | + # Audit each pnpm-lock.yaml found in the repo + status=0 + while IFS= read -r dir; do + echo "::group::pnpm audit $dir" + if ! (cd "$dir" && pnpm audit --audit-level low); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'pnpm-lock.yaml' -not -path '*/node_modules/*' -exec dirname {} \;) + exit $status + + audit-go: + name: govulncheck + needs: detect + if: needs.detect.outputs.gomod == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: "stable" + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + + - name: Audit Go dependencies + run: | + status=0 + while IFS= read -r dir; do + echo "::group::govulncheck $dir" + if ! (cd "$dir" && govulncheck ./...); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'go.mod' -not -path '*/vendor/*' -exec dirname {} \;) + exit $status + + audit-cargo: + name: cargo audit + needs: detect + if: needs.detect.outputs.cargo == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit@0.22.1 --locked + + - name: Audit Cargo dependencies + run: | + # cargo audit operates on Cargo.lock at workspace root + # For workspaces, a single audit at root covers all crates + status=0 + while IFS= read -r dir; do + echo "::group::cargo audit $dir" + if ! (cd "$dir" && cargo generate-lockfile 2>/dev/null; cargo audit); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'Cargo.toml' -not -path '*/target/*' -exec dirname {} \; | sort -u) + exit $status + + audit-pip: + name: pip-audit + needs: detect + if: needs.detect.outputs.pip == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.x" + + - name: Install pip-audit + run: pip install pip-audit==2.9.0 + + - name: Audit Python dependencies + run: | + status=0 + # Audit each Python project found in the repo + while IFS= read -r dir; do + echo "::group::pip-audit $dir" + if [ -f "$dir/pyproject.toml" ]; then + if ! pip-audit "$dir"; then + status=1 + fi + elif [ -f "$dir/requirements.txt" ]; then + if ! pip-audit -r "$dir/requirements.txt"; then + status=1 + fi + fi + echo "::endgroup::" + done < <( + { + find . -name 'pyproject.toml' -not -path '*/.venv/*' -not -path '*/venv/*' -exec dirname {} \; + find . -name 'requirements.txt' -not -path '*/.venv/*' -not -path '*/venv/*' -exec dirname {} \; + } | sort -u + ) + exit $status