diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3ec60ef --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +# Keep the SHA-pinned GitHub Actions current. Dependabot bumps the commit pin AND the trailing +# "# vX.Y.Z" comment together, and opens PRs for any security advisories affecting an action we use. +# +# Scope is github-actions ONLY (#11): this repo is a static Hugo site with no app dependencies — +# no package.json, go.mod, pip, or docker to track. Hugo itself is installed by version-pinned +# `wget` of a release .deb in the workflows (HUGO_VERSION), which Dependabot cannot see, so Hugo +# bumps stay manual (see #15). +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # github-actions ecosystem watches .github/workflows/ + schedule: + interval: "weekly" + commit-message: + prefix: "ci" # -> "ci(deps): bump actions/checkout ..." + include: "scope" + labels: + - "infra" + groups: + # One rollup PR for all action bumps rather than one-per-action — low-noise for a repo this small. + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64f722..3483934 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,10 @@ jobs: HTMLTEST_VERSION: "0.17.0" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + persist-credentials: false # zizmor: artipacked - name: Install Hugo (extended) run: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 325acad..6c6a9aa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,10 +9,10 @@ on: - cron: "17 6 * * *" workflow_dispatch: +# Least-privilege floor: the build job only reads the tree. Only the deploy job +# needs pages:write + id-token:write, scoped to it below. (zizmor: excessive-permissions) permissions: contents: read - pages: write - id-token: write # Allow one concurrent deployment; don't cancel an in-progress production deploy. concurrency: @@ -35,13 +35,14 @@ jobs: https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ && sudo dpkg -i ${{ runner.temp }}/hugo.deb - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive fetch-depth: 0 + persist-credentials: false # zizmor: artipacked - name: Setup Pages id: pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 with: enablement: true - name: Refresh release versions (best-effort) @@ -52,13 +53,16 @@ jobs: env: HUGO_ENVIRONMENT: production TZ: UTC + # Pass the Pages URL through env rather than expanding it inline in the + # script (avoids template-injection; zizmor: template-injection). + BASE_URL: ${{ steps.pages.outputs.base_url }} run: | hugo \ --gc \ --minify \ - --baseURL "${{ steps.pages.outputs.base_url }}/" + --baseURL "${BASE_URL}/" - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ./public @@ -68,7 +72,11 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build + # Only this job touches Pages — grant the write scopes here, not repo-wide. + permissions: + pages: write + id-token: write steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..4a000de --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,75 @@ +name: Security + +# Supply-chain & secrets gates (#11): +# - gitleaks: scan the full git history for committed secrets (tokens, credentials) on every +# push and PR. +# - zizmor: static-audit the GitHub Actions workflows themselves (template injection, over-broad +# GITHUB_TOKEN, unpinned actions, credential persistence) AND cross-reference the actions we +# pin against the GitHub Advisory Database (online audit). +# Dependabot (github-actions) lives in .github/dependabot.yml; the matching gitleaks pre-commit +# hook lives in .pre-commit-config.yaml. + +on: + push: + branches: [main] + pull_request: + # Re-audit on a schedule so a newly-published advisory against an action we pin trips the gate + # even during quiet periods with no pushes — the online zizmor audit is time-varying by design. + schedule: + - cron: "0 7 * * 1" # Mondays 07:00 UTC + +# Both jobs only read the tree to scan it. Pin the floor to read-only (zizmor: excessive-permissions). +permissions: + contents: read + +jobs: + gitleaks: + name: Secret scan (gitleaks) + runs-on: ubuntu-24.04 + # The weekly tick exists for zizmor's advisory re-audit; history doesn't change between pushes, + # so there's nothing new for gitleaks to scan on a schedule. + if: github.event_name != 'schedule' + env: + # Pinned + checksum-verified — reproducible and immune to runner-image drift. Keep + # GITLEAKS_VERSION in lockstep with .pre-commit-config.yaml. + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 # scan EVERY commit, not just the tip — a secret is still a leak once pushed + persist-credentials: false # zizmor: artipacked + - name: Install pinned gitleaks + run: | + set -euo pipefail + tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}" -o "$tarball" + echo "${GITLEAKS_SHA256} ${tarball}" | sha256sum -c - + tar -xzf "$tarball" gitleaks + sudo install gitleaks /usr/local/bin/gitleaks + gitleaks version + # Full-history scan with the built-in ruleset. --redact keeps any match out of the public logs; + # the job still fails (non-zero exit) so a leak blocks the merge. + - name: Scan git history for secrets + run: gitleaks git . --redact --no-banner --verbose + + zizmor: + name: Workflow audit (zizmor) + runs-on: ubuntu-24.04 + env: + ZIZMOR_VERSION: "1.25.2" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false # zizmor: artipacked + # pipx is preinstalled on ubuntu-24.04. + - name: Install pinned zizmor + run: pipx install "zizmor==${ZIZMOR_VERSION}" + # Online audits ON (zizmor's default): GH_TOKEN lets the `known-vulnerable-actions` audit query + # the GitHub Advisory Database, so a CVE disclosed against an action we pin fails the gate. The + # built-in token (read-only here) is enough — advisory data is public; it's only for API access. + # This complements Dependabot: zizmor blocks the merge, Dependabot opens the bump. + - name: Audit GitHub Actions workflows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: zizmor .github/workflows/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..542e6fb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +# Pre-commit hooks for the site. Install once per clone: +# +# pipx install pre-commit # or: pip install pre-commit +# pre-commit install +# +# Scope here is the secret-scanning gate from #11: gitleaks, pinned to the SAME version CI runs +# (.github/workflows/security.yml) so a leak is caught locally before it's ever pushed. +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 # keep in lockstep with GITLEAKS_VERSION in .github/workflows/security.yml + hooks: + - id: gitleaks diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c09f05..e38d4df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,17 @@ not in the templates. See the [README](README.md#where-the-content-lives) for th ``` 4. Update the README or other docs for any user-facing change. +## Secret scanning + +CI runs [gitleaks](https://github.com/gitleaks/gitleaks) over the full history on every push and PR, +so an accidentally committed token or credential blocks the merge. Catch it locally first by +installing the pre-commit hook (it runs the same pinned gitleaks on staged changes): + +```bash +pipx install pre-commit # or: pip install pre-commit +pre-commit install +``` + ## Opening a pull request - Target the `main` branch and fill out the PR template.