From a8e4ae352699e23570eba1fbe2480782dad1fcaa Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Thu, 27 Aug 2026 16:05:56 +0530 Subject: [PATCH 1/4] chore(deps): add Dependabot to rotate the pinned Semgrep CI image (DEVA11Y-476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Semgrep workflow container image is already pinned by immutable @sha256 digest (PR #15), which is the DEVA11Y-476 chain-breaker for the C-001 chain (DEVA11Y-485: CI image compromise -> mutable main -> SPM plugin RCE). A static digest, however, never receives upstream security patches — the current pin already lags returntocorp/semgrep:latest. Add .github/dependabot.yml with a `docker` ecosystem entry over /.github/workflows so Dependabot bumps the pinned digest to the newest build on a weekly cadence, keeping immutability without freezing the image. Co-Authored-By: Claude Opus 4.8 --- .github/dependabot.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..31d8e65 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# Dependabot configuration. +# +# Keeps the digest-pinned Semgrep CI container image current (DEVA11Y-476 / chain +# DEVA11Y-485). The image in .github/workflows/Semgrep.yml is pinned to an immutable +# @sha256 digest for supply-chain integrity; a static digest never receives upstream +# security patches, so Dependabot's `docker` ecosystem watches the image reference in +# the workflow's job-level `container:` and opens PRs that bump the pin to the newest +# digest as returntocorp/semgrep is rebuilt. +version: 2 +updates: + # Docker image references in GitHub Actions workflow files (job `container:` / `services:`). + - package-ecosystem: "docker" + directory: "/.github/workflows" + schedule: + interval: "weekly" + commit-message: + prefix: "chore(deps)" + labels: + - "dependencies" + - "security" + open-pull-requests-limit: 5 From d97c681f4cce30185fb623d9f746e74ccf5862e6 Mon Sep 17 00:00:00 2001 From: Rishabh Jain <43724509+Crash0v3rrid3@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:17:52 +0530 Subject: [PATCH 2/4] chore(ci): add cooldown to Dependabot Semgrep image updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a release-age cooldown so Dependabot does not adopt a freshly-published returntocorp/semgrep digest immediately — a poisoned-image would otherwise be pulled in within a day of publication, the exact window digest-pinning is meant to defend (DEVA11Y-476 / chain DEVA11Y-485). Also resolves the semgrep/ci finding flagging the update config for lacking a minimum release age. Co-Authored-By: Claude Opus 4.8 --- .github/dependabot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 31d8e65..547db3d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,11 @@ updates: directory: "/.github/workflows" schedule: interval: "weekly" + # Wait for a digest to age before adopting it. A brand-new image build could be + # a poisoned upstream tag; a cooldown gives time for detection/revocation before + # Dependabot bumps the pin, without holding back genuine security patches for long. + cooldown: + default-days: 7 commit-message: prefix: "chore(deps)" labels: From ca95c3a95a0032d1c56af3157d7f0a44700e5518 Mon Sep 17 00:00:00 2001 From: Rishabh Jain <43724509+Crash0v3rrid3@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:40:23 +0530 Subject: [PATCH 3/4] chore(ci): rotate GitHub Actions via Dependabot + drift-alarm the Semgrep image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot's `docker` ecosystem cannot discover a `container:` image ref in a workflow file (dependabot-core#5819), so the previous `docker`/`/.github/workflows` config would never rotate the pin and would fail a weekly Dependabot job. Pivot: - dependabot.yml: replace the unusable `docker` entry with a `github-actions` entry (directory "/", weekly, cooldown default-days 7) — real, supported rotation for the action `uses:` pins here, several of which are 3 years stale. Drop the undefined `security` label and the redundant open-pull-requests-limit. Cooldown retained (also clears the semgrep/ci dependabot-missing-cooldown rule). - Add semgrep-image-pin-drift.yml: read-only scheduled/dispatch job that alarms (fails) when the pinned returntocorp/semgrep@sha256 digest drifts from :latest, keeping a human in the loop on the image bump (better for the C-001 threat model than auto-adopting latest). Co-Authored-By: Claude Opus 4.8 --- .github/dependabot.yml | 32 ++++++------ .github/workflows/semgrep-image-pin-drift.yml | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/semgrep-image-pin-drift.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 547db3d..f15e400 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,26 +1,30 @@ # Dependabot configuration. # -# Keeps the digest-pinned Semgrep CI container image current (DEVA11Y-476 / chain -# DEVA11Y-485). The image in .github/workflows/Semgrep.yml is pinned to an immutable -# @sha256 digest for supply-chain integrity; a static digest never receives upstream -# security patches, so Dependabot's `docker` ecosystem watches the image reference in -# the workflow's job-level `container:` and opens PRs that bump the pin to the newest -# digest as returntocorp/semgrep is rebuilt. +# 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 on drift so a human bumps it deliberately. version: 2 updates: - # Docker image references in GitHub Actions workflow files (job `container:` / `services:`). - - package-ecosystem: "docker" - directory: "/.github/workflows" + # GitHub Actions `uses:` pins in .github/workflows (directory "/" — the ecosystem + # searches the workflows dir itself). + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" - # Wait for a digest to age before adopting it. A brand-new image build could be - # a poisoned upstream tag; a cooldown gives time for detection/revocation before - # Dependabot bumps the pin, without holding back genuine security patches for long. + # Wait for a release to age before adopting it: a brand-new tag could be a + # poisoned upstream release; a cooldown gives time for detection/revocation + # before Dependabot bumps the pin, without holding back genuine patches for long. cooldown: default-days: 7 commit-message: prefix: "chore(deps)" labels: - "dependencies" - - "security" - open-pull-requests-limit: 5 diff --git a/.github/workflows/semgrep-image-pin-drift.yml b/.github/workflows/semgrep-image-pin-drift.yml new file mode 100644 index 0000000..97f915e --- /dev/null +++ b/.github/workflows/semgrep-image-pin-drift.yml @@ -0,0 +1,49 @@ +# Alarms when the pinned Semgrep CI container image digest in Semgrep.yml drifts +# from returntocorp/semgrep:latest (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 poisoned upstream tag is the +# risk — this job keeps a human in the loop: it fails on drift so someone bumps the +# pin deliberately after reviewing the new digest. Read-only; no write permissions. +name: Semgrep image pin drift +on: + schedule: + - cron: "0 7 * * 1" # Mondays 07:00 UTC + workflow_dispatch: +permissions: + contents: read +jobs: + drift: + name: Pinned digest vs returntocorp/semgrep:latest + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Compare pinned digest against returntocorp/semgrep:latest + run: | + set -uo pipefail + pinned=$(grep -oE 'returntocorp/semgrep@sha256:[0-9a-f]{64}' \ + .github/workflows/Semgrep.yml | head -n1 | cut -d@ -f2) + 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:returntocorp/semgrep:pull" | jq -r .token) + latest=$(curl -fsSI -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://registry-1.docker.io/v2/returntocorp/semgrep/manifests/latest" \ + | tr -d '\r' | awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}') + if [ -z "$latest" ]; then + echo "::error::Could not resolve returntocorp/semgrep:latest digest from the registry" + exit 1 + fi + echo "pinned: $pinned" + echo "latest: $latest" + if [ "$pinned" = "$latest" ]; then + echo "Semgrep image pin is current." + else + echo "::error::Semgrep image pin drifted ($pinned != $latest) — review and bump the digest in .github/workflows/Semgrep.yml" + exit 1 + fi From abc0e1d4ae585f34b0af613b28db98505b578295 Mon Sep 17 00:00:00 2001 From: Rishabh Jain <43724509+Crash0v3rrid3@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:44:28 +0530 Subject: [PATCH 4/4] chore(ci): address PR#38 round-2 review (cooldown/drift/pin/guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves Nishant's second review on PR #38: 1. cooldown is inert for the github-actions Dependabot ecosystem — kept only to satisfy Semgrep's dependabot-missing-cooldown rule; documented as inert in dependabot.yml (no real release-age protection for action bumps). 2. Semgrep image drift workflow: made the intended-failure branches reachable under GHA's injected `bash -e` (|| true on the grep/token/latest command substitutions + explicit empty-token guard), same class of fix as #37 66249fe. Verified: happy/stale/pin-removed/registry-down all annotate+exit. 3. Reworked the alarm from "differs from :latest" (red most weeks → muted) to "pinned image older than 45 days" (actionable). Bumped the Semgrep image pin to the current :latest digest so it lands green: f682953… -> f1f7b71861c7b28b6e0f661225a2c4f58a484f5d0f182465c6d6b3b22f972ade (created 2026-08-20, ~8 days old). 4. Removed the job-level `if: github.actor != 'dependabot[bot]'` guard on Semgrep.yml and scoped it to only the privileged upload-sarif step, so Dependabot PRs that bump the action pins living in Semgrep.yml still get scanned; only the security-events:write SARIF upload is skipped for them. Also documented the scheduled-workflow ops caveat (60-day inactivity disable; failed runs notify only the last cron editor) in the drift workflow. Co-Authored-By: Claude Opus 4.8 --- .github/dependabot.yml | 12 +- .github/workflows/Semgrep.yml | 11 +- .github/workflows/semgrep-image-pin-drift.yml | 110 ++++++++++++++---- 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f15e400..662fff9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,7 +10,7 @@ # `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 on drift so a human bumps it deliberately. +# 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 @@ -19,9 +19,13 @@ updates: directory: "/" schedule: interval: "weekly" - # Wait for a release to age before adopting it: a brand-new tag could be a - # poisoned upstream release; a cooldown gives time for detection/revocation - # before Dependabot bumps the pin, without holding back genuine patches for long. + # 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: 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 index 97f915e..1b0320c 100644 --- a/.github/workflows/semgrep-image-pin-drift.yml +++ b/.github/workflows/semgrep-image-pin-drift.yml @@ -1,12 +1,23 @@ -# Alarms when the pinned Semgrep CI container image digest in Semgrep.yml drifts -# from returntocorp/semgrep:latest (DEVA11Y-476 / chain DEVA11Y-485). +# 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 poisoned upstream tag is the -# risk — this job keeps a human in the loop: it fails on drift so someone bumps the -# pin deliberately after reviewing the new digest. Read-only; no write permissions. -name: Semgrep image pin drift +# — 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 @@ -14,36 +25,91 @@ on: permissions: contents: read jobs: - drift: - name: Pinned digest vs returntocorp/semgrep:latest + freshness: + name: Pinned Semgrep image age runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Compare pinned digest against returntocorp/semgrep:latest + - 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) - if [ -z "$pinned" ]; then + .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:returntocorp/semgrep:pull" | jq -r .token) - latest=$(curl -fsSI -H "Authorization: Bearer $token" \ + + 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' \ - "https://registry-1.docker.io/v2/returntocorp/semgrep/manifests/latest" \ - | tr -d '\r' | awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}') - if [ -z "$latest" ]; then - echo "::error::Could not resolve returntocorp/semgrep:latest digest from the registry" + -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 - echo "pinned: $pinned" - echo "latest: $latest" - if [ "$pinned" = "$latest" ]; then - echo "Semgrep image pin is current." + + # 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 - echo "::error::Semgrep image pin drifted ($pinned != $latest) — review and bump the digest in .github/workflows/Semgrep.yml" + 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."