diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..662fff9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Dependabot configuration. +# +# Rotates the pinned GitHub Actions used across .github/workflows (DEVA11Y-476 / +# chain DEVA11Y-485). Action `uses:` pins are pinned to immutable commit SHAs for +# supply-chain integrity; a static SHA never receives upstream security patches, so +# Dependabot's `github-actions` ecosystem opens PRs that bump each pin as new +# releases ship. +# +# NOTE: Dependabot cannot rotate the `container:` image digest in Semgrep.yml — its +# `docker` ecosystem only discovers Dockerfiles/Containerfiles, Kubernetes manifests, +# Helm values and Compose files, not image refs in workflow files +# (dependabot-core#5819). That digest is watched by .github/workflows/semgrep-image-pin-drift.yml +# instead, which alarms when the pinned image goes stale so a human bumps it deliberately. +version: 2 +updates: + # GitHub Actions `uses:` pins in .github/workflows (directory "/" — the ecosystem + # searches the workflows dir itself). + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + # INERT for the github-actions ecosystem: `cooldown` is NOT among the ecosystems + # GitHub supports it for (docker/bundler/npm/pip/… do; github-actions does not), + # so this key does nothing here. It is kept ONLY to satisfy Semgrep's + # `dependabot-missing-cooldown` rule (a schema-level check that does not know the + # key is ecosystem-inert). It provides no actual release-age protection for action + # bumps — that would require Renovate's `minimumReleaseAge`. Do not treat actions + # bumped by this config as having aged past a cooldown window. + cooldown: + default-days: 7 + commit-message: + prefix: "chore(deps)" + labels: + - "dependencies" diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml index a5a7a59..b5e3e66 100644 --- a/.github/workflows/Semgrep.yml +++ b/.github/workflows/Semgrep.yml @@ -28,10 +28,8 @@ jobs: container: # Pinned by digest for supply-chain integrity (DEVA11Y-476). # To update: docker manifest inspect returntocorp/semgrep:latest - image: returntocorp/semgrep@sha256:f682953ce85e3725f4a4dd94bd7ad13e570bb6b2c7a8cf7c6e38a9eac89239b2 + image: returntocorp/semgrep@sha256:f1f7b71861c7b28b6e0f661225a2c4f58a484f5d0f182465c6d6b3b22f972ade - # Skip any PR created by dependabot to avoid permission issues: - if: (github.actor != 'dependabot[bot]') steps: # Fetch project source with GitHub Actions Checkout. @@ -46,5 +44,10 @@ jobs: uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 with: sarif_file: semgrep.sarif - if: always() + # SARIF upload needs security-events: write, which Dependabot-triggered runs + # do not get; skip only this privileged step for dependabot so the scan itself + # still runs on Dependabot PRs (which bump the action pins that live in this + # very workflow). always() keeps upload on non-dependabot runs even if the scan + # step reports findings. + if: always() && github.actor != 'dependabot[bot]' diff --git a/.github/workflows/semgrep-image-pin-drift.yml b/.github/workflows/semgrep-image-pin-drift.yml new file mode 100644 index 0000000..1b0320c --- /dev/null +++ b/.github/workflows/semgrep-image-pin-drift.yml @@ -0,0 +1,115 @@ +# Alarms when the pinned Semgrep CI container image digest in Semgrep.yml goes STALE +# (DEVA11Y-476 / chain DEVA11Y-485). +# +# Dependabot cannot rotate a `container:` image digest in a workflow file +# (dependabot-core#5819), so instead of auto-adopting whatever `latest` resolves to +# — undesirable for the C-001 threat model, where a freshly-poisoned upstream tag is +# the risk — this job keeps a human in the loop. It does NOT fail merely because +# `latest` has moved (returntocorp/semgrep is rebuilt ~weekly, so a "differs from +# latest" alarm would be red most weeks and get muted). Instead it fails only when +# the *pinned* image is older than MAX_AGE_DAYS, which is actionable ("your pin is N +# days stale") rather than noise ("upstream pushed yesterday"). Read-only; no write +# permissions. +# +# OPS CAVEAT: GitHub disables scheduled workflows after 60 days of repository +# inactivity, and a failed scheduled run only notifies whoever last edited the cron +# (no team routing here). So this alarm is a backstop, NOT a substitute for the +# committed digest pin in Semgrep.yml — do not rely on it as the sole control. If +# stronger routing is wanted later, add an issue-creating or Slack step (would need +# additional permissions, intentionally omitted here to keep this job read-only). +name: Semgrep image pin freshness +on: + schedule: + - cron: "0 7 * * 1" # Mondays 07:00 UTC + workflow_dispatch: +permissions: + contents: read +jobs: + freshness: + name: Pinned Semgrep image age + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Fail if the pinned Semgrep image digest is stale + run: | + # NOTE: GitHub runs this as `bash -e {0}`; `set -uo pipefail` does not clear + # that injected -e. Every command substitution whose failure must be handled + # by a guard below (rather than aborting the step with no annotation) is + # suffixed with `|| true` — otherwise a non-matching grep, a SIGPIPE from + # `| head`, or a registry error would exit the step before the guard runs, + # leaving a bare red square with no ::error:: (the trap fixed in PR #37). + set -uo pipefail + MAX_AGE_DAYS=45 + repo="returntocorp/semgrep" + + pinned=$(grep -oE 'returntocorp/semgrep@sha256:[0-9a-f]{64}' \ + .github/workflows/Semgrep.yml | head -n1 | cut -d@ -f2 || true) + if [ -z "${pinned:-}" ]; then + echo "::error::Could not find a returntocorp/semgrep@sha256 pin in .github/workflows/Semgrep.yml" + exit 1 + fi + + token=$(curl -fsS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull" \ + | jq -r .token || true) + if [ -z "${token:-}" ] || [ "${token}" = "null" ]; then + echo "::error::Could not obtain a Docker registry auth token for ${repo} (registry auth failure)" + exit 1 + fi + + man=$(curl -fsS -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ + "https://registry-1.docker.io/v2/${repo}/manifests/${pinned}" || true) + if [ -z "${man:-}" ]; then + echo "::error::Could not fetch the manifest for pinned digest ${pinned} (registry unreachable or digest gone)" + exit 1 + fi + + # Multi-arch index: descend into the linux/amd64 child image manifest. + child=$(printf '%s' "${man}" \ + | jq -r '(.manifests // [])[] | select(.platform.os=="linux" and .platform.architecture=="amd64") | .digest' \ + | head -n1 || true) + if [ -n "${child:-}" ] && [ "${child}" != "null" ]; then + imgman=$(curl -fsS -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ + "https://registry-1.docker.io/v2/${repo}/manifests/${child}" || true) + else + imgman="${man}" + fi + if [ -z "${imgman:-}" ]; then + echo "::error::Could not fetch the image manifest for pinned digest ${pinned}" + exit 1 + fi + + cfg=$(printf '%s' "${imgman}" | jq -r '.config.digest // empty' || true) + if [ -z "${cfg:-}" ]; then + echo "::error::Could not locate the image config descriptor for pinned digest ${pinned}" + exit 1 + fi + + created=$(curl -fsS -L -H "Authorization: Bearer ${token}" \ + "https://registry-1.docker.io/v2/${repo}/blobs/${cfg}" | jq -r '.created // empty' || true) + if [ -z "${created:-}" ]; then + echo "::error::Could not read the created timestamp from the image config for ${pinned}" + exit 1 + fi + + created_epoch=$(date -u -d "${created}" +%s 2>/dev/null || true) + if [ -z "${created_epoch:-}" ]; then + echo "::error::Could not parse the image created timestamp: ${created}" + exit 1 + fi + now_epoch=$(date -u +%s) + age_days=$(( (now_epoch - created_epoch) / 86400 )) + + echo "pinned digest: ${pinned}" + echo "image created: ${created} (${age_days} days ago)" + if [ "${age_days}" -gt "${MAX_AGE_DAYS}" ]; then + echo "::error::Pinned Semgrep image is ${age_days} days old (> ${MAX_AGE_DAYS}) — re-resolve returntocorp/semgrep:latest and bump the digest in .github/workflows/Semgrep.yml" + exit 1 + fi + echo "::notice::Pinned Semgrep image is ${age_days} days old (<= ${MAX_AGE_DAYS}); no action needed."