From 8494b9ed6426a9110fd990e2451f60e52a758330 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 15:22:24 -0700 Subject: [PATCH 1/7] security(workflow-audit): report the unexplained, not the routine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit filed an issue for every commit touching .github/workflows/, which in practice meant a near-daily issue about changes that had already been reviewed. Half of all reported commits were one pending Renovate PR, re-reported nightly because rebasing mints a new SHA. Fifteen open issues, zero true positives. Classify two routine sources and omit them, each on evidence the credential this audit exists to watch cannot mint: - Renovate pin bumps must carry a valid GitHub signature AND change nothing but the ref of an already-referenced action. The signature because `%an` is free text from git config; the name-unchanged test because a pin-only diff can still repoint actions/checkout at an attacker's action. - tend regenerations must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header. Identity proves nothing here — TEND_BOT_TOKEN is the credential in question — so reproducibility is the only acceptable evidence. Both fail open: any error or ambiguity reports the commit. Commits merged to main are still reported; review is not proof, and SECURITY.md names social-engineering an admin toward a merge as an accepted risk. Not deduplicated by branch or file set either, which would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. Also fixes a pre-existing blind spot: `git show --name-only` reports nothing for a merge, which produced contentless reports and would have hidden an evil merge. Merges are now diffed against every parent and intersected, leaving only what the merge itself introduced. When nothing is unexplained, no issue is filed — the run summary carries the record. The SECURITY.md liveness check keys on a successful run, not on an issue existing. Verified against the last 10 days: 5 commits, all explained, 0 reported (previously 6 issues). Negative tests confirm a tampered tend regen, an unsigned commit, an action repointed to evil/*, and a pin bump with an injected `run:` line are all still reported. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/workflow-audit.yaml | 210 +++++++++++++++++++++++--- SECURITY.md | 9 +- 2 files changed, 197 insertions(+), 22 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index 846d1bed..96969315 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -8,6 +8,28 @@ name: workflow-audit # Gap-resistant: the "since" lower bound comes from the previous # successful run's API timestamp, so a failed run pushes the window # forward rather than skipping commits. +# +# Reports the *unexplained*. Two routine sources are classified and +# skipped, each on evidence an attacker cannot mint rather than on the +# commit's self-declared author (`%an` is free text from git config): +# +# - Renovate pin bumps — GitHub-signed (`verification.verified`), and +# every changed line in .github/workflows/ is a `uses:` line whose +# action name is unchanged, only its ref. "Same action, new pin." +# - tend regeneration — the changed tend-*.yaml files reproduce +# byte-for-byte from `uvx tend@ init` at the version in the +# files' own generated header, run against that commit's own +# .config/tend.yaml. +# +# Both classifiers fail open: any error, ambiguity, or unparseable input +# reports the commit. A silent run is the healthy steady state and keeps +# the SECURITY.md 48-hour liveness check green — that check keys on a +# successful *run*, not on an issue existing. +# +# Deliberately not deduped by (branch, file-set): that would let a +# benign change be reported once and a later force-push of malicious +# content to the same branch and files pass unremarked. Every commit is +# classified on its own content. on: schedule: @@ -30,6 +52,9 @@ jobs: - name: Fetch all branches run: git fetch origin '+refs/heads/*:refs/remotes/origin/*' + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - name: Audit workflow file changes env: GH_TOKEN: ${{ github.token }} @@ -49,34 +74,183 @@ jobs: if [ -z "$COMMITS" ]; then echo "No workflow file changes since $SINCE." + echo "No workflow file changes since \`$SINCE\`." >> "$GITHUB_STEP_SUMMARY" exit 0 fi - COUNT=$(echo "$COMMITS" | wc -l | tr -d ' ') + # A routine Renovate pin bump: GitHub-signed, and every changed line + # under .github/workflows/ is a `uses:` whose action name is unchanged. + # The signature is the load-bearing half — `.author.login` resolves + # from the commit's email, which anyone can set, so identity alone + # proves nothing. The name-unchanged test is the other half: a + # pin-only diff can still repoint `actions/checkout` at `evil/action`, + # which is a rewrite, not a bump. + is_renovate_pin_bump() { + local sha="$1" meta verified reason login diff changed removed added + + meta=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" \ + --jq '[.commit.verification.verified, .commit.verification.reason, (.author.login // "")] | @tsv') || return 1 + verified=$(echo "$meta" | cut -f1) + reason=$(echo "$meta" | cut -f2) + login=$(echo "$meta" | cut -f3) + + [ "$verified" = "true" ] || return 1 + [ "$reason" = "valid" ] || return 1 + [ "$login" = "renovate[bot]" ] || return 1 + + diff=$(git show --format='' -U0 "$sha" -- .github/workflows/) || return 1 + # Content lines only — drop diff headers and hunk markers. + changed=$(printf '%s\n' "$diff" \ + | grep -E '^[+-]' \ + | grep -Ev '^(\+\+\+|---)' || true) + [ -n "$changed" ] || return 1 + + # Every changed line must be a `uses:` line. + if printf '%s\n' "$changed" | grep -qvE '^[+-][[:space:]]*(- )?uses:[[:space:]]'; then + return 1 + fi + + # ...and the set of action names must be identical on both sides, + # so only the ref after `@` moved. + removed=$(printf '%s\n' "$changed" | grep '^-' \ + | sed -E 's/^-[[:space:]]*(- )?uses:[[:space:]]*//; s/@.*//' | sort) + added=$(printf '%s\n' "$changed" | grep '^+' \ + | sed -E 's/^\+[[:space:]]*(- )?uses:[[:space:]]*//; s/@.*//' | sort) + [ "$removed" = "$added" ] || return 1 + + return 0 + } + + # A routine tend regeneration: only tend-*.yaml changed, and those + # files reproduce byte-for-byte from the upstream generator at the + # version their own header names. Identity is irrelevant here — the + # bot's own PAT is the credential this audit exists to watch, so the + # only acceptable evidence is that the content is reproducible. + is_tend_regen() { + local sha="$1" files version tmp rc + files=$(git show --name-only --pretty='' "$sha" -- .github/workflows/) + [ -n "$files" ] || return 1 + if printf '%s\n' "$files" | grep -qvE '^\.github/workflows/tend-[a-z-]+\.yaml$'; then + return 1 + fi + + version=$(git show "$sha:.github/workflows/tend-review.yaml" 2>/dev/null \ + | sed -nE '1s/^# Generated by tend ([0-9]+\.[0-9]+\.[0-9]+)\..*/\1/p') + [ -n "$version" ] || return 1 + + tmp=$(mktemp -d) + rc=1 + if git worktree add --detach "$tmp" "$sha" >/dev/null 2>&1; then + if (cd "$tmp" && uvx "tend@$version" init >/dev/null 2>&1); then + if [ -z "$(cd "$tmp" && git status --porcelain -- .github/workflows/)" ]; then + rc=0 + fi + fi + git worktree remove --force "$tmp" >/dev/null 2>&1 || true + fi + rm -rf "$tmp" + return $rc + } + REPORT=$(mktemp) - { - echo "$COUNT commit(s) touching \`.github/workflows/\` since \`$SINCE\`:" - echo "" - for sha in $COMMITS; do - AUTHOR=$(git show -s --format='%an <%ae>' "$sha") - DATE=$(git show -s --format='%ci' "$sha") - SUBJECT=$(git show -s --format='%s' "$sha") - REFS=$(git branch -a --contains "$sha" 2>/dev/null \ - | grep -v 'HEAD ->' | head -10 \ - | sed 's/^[[:space:]]*//' | paste -sd ', ' -) - FILES=$(git show --name-only --pretty='' "$sha" -- .github/workflows/) + SKIPPED=$(mktemp) + COUNT=0 + + # What this commit itself changed under .github/workflows/. + # + # For a merge, `git show --name-only` reports nothing, which would + # otherwise produce a contentless report *and* hide an evil merge — + # content present in the merge result but in neither parent. So a + # merge is diffed against every parent and intersected: a file taken + # wholesale from one side is unchanged relative to that side and drops + # out, leaving only what the merge itself introduced. + own_changes() { + local sha="$1" parents nparents acc cur first=1 + parents=$(git rev-list --parents -n1 "$sha" | cut -d' ' -f2-) + nparents=$(printf '%s\n' "$parents" | wc -w | tr -d ' ') + if [ "$nparents" -le 1 ]; then + git show --name-only --pretty='' "$sha" -- .github/workflows/ + return + fi + for p in $parents; do + cur=$(git diff --name-only "$p" "$sha" -- .github/workflows/ | sort -u) + if [ "$first" -eq 1 ]; then + acc="$cur"; first=0 + else + acc=$(comm -12 <(printf '%s\n' "$acc") <(printf '%s\n' "$cur")) + fi + done + printf '%s\n' "$acc" | sed '/^$/d' + } + + for sha in $COMMITS; do + SUBJECT=$(git show -s --format='%s' "$sha") + FILES=$(own_changes "$sha") + + # Nothing attributable to this commit — a merge that only carried + # branch commits the audit sees on their own. + if [ -z "$FILES" ]; then + continue + fi + + if is_renovate_pin_bump "$sha"; then + echo "- \`${sha:0:7}\` — $SUBJECT (signed Renovate pin bump)" >> "$SKIPPED" + continue + fi + if is_tend_regen "$sha"; then + echo "- \`${sha:0:7}\` — $SUBJECT (reproduces from the tend generator)" >> "$SKIPPED" + continue + fi + + COUNT=$((COUNT + 1)) + AUTHOR=$(git show -s --format='%an <%ae>' "$sha") + DATE=$(git show -s --format='%ci' "$sha") + REFS=$(git branch -a --contains "$sha" 2>/dev/null \ + | grep -v 'HEAD ->' | head -10 \ + | sed 's/^[*[:space:]]*//' | paste -sd ', ' -) + { echo "### \`${sha:0:7}\` — $SUBJECT" echo "" - echo "- **Author:** $AUTHOR" + echo "- **Author:** $AUTHOR (self-declared; not proof of origin)" echo "- **Date:** $DATE" - echo "- **Refs:** $REFS" + echo "- **Refs:** ${REFS:-none — unreferenced commit}" echo "- **Files:**" echo "$FILES" | sed 's|^| - `|; s|$|`|' echo "- [View diff](https://github.com/$GITHUB_REPOSITORY/commit/$sha)" echo "" - done - } > "$REPORT" + } >> "$REPORT" + done + + { + echo "## workflow-audit" + echo "" + echo "Window: since \`$SINCE\`" + echo "" + if [ -s "$SKIPPED" ]; then + echo "Explained (not reported):" + echo "" + cat "$SKIPPED" + echo "" + fi + echo "Unexplained: $COUNT" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$COUNT" -eq 0 ]; then + echo "No unexplained workflow changes since $SINCE." + exit 0 + fi + + BODY=$(mktemp) + { + echo "$COUNT unexplained commit(s) touching \`.github/workflows/\` since \`$SINCE\`." + echo "" + echo "Routine Renovate pin bumps and reproducible tend regenerations are" + echo "classified and omitted — see the run summary for what was skipped." + echo "Everything below needs a human to account for it." + echo "" + cat "$REPORT" + } > "$BODY" - TITLE="[workflow-audit] $COUNT change(s) on $(date -u +%Y-%m-%d)" + TITLE="[workflow-audit] $COUNT unexplained change(s) on $(date -u +%Y-%m-%d)" gh issue create --repo "$GITHUB_REPOSITORY" \ - --title "$TITLE" --body-file "$REPORT" + --title "$TITLE" --body-file "$BODY" diff --git a/SECURITY.md b/SECURITY.md index 0440551b..cc5c6a87 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,21 +55,22 @@ An attacker who lands a prompt injection in tend's harness can reach three secre **Inert secret plumbing.** Every generated `tend-*.yaml` passes `anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}` to `max-sixty/tend/claude`. No such secret exists at repo or org level, so today it resolves to the empty string and the harness authenticates with `CLAUDE_CODE_OAUTH_TOKEN` instead. The input is upstream-generated and cannot be removed locally without being overwritten by the next nightly regen, so the risk is handled by enforcement rather than deletion: the moment anyone adds an `ANTHROPIC_API_KEY` secret for an unrelated reason, eight bot-triggered workflows would start reading it with no code change and no review. The FAIL IF below makes that addition a deliberate, documented expansion of the bot's reach. -**Org-level secrets.** Secrets shared with this repo from the `diffplug` org are reachable by any workflow the bot can author, exactly like repo-level ones, and they do not appear in this repo's own secret listing (`gh api repos/diffplug/dormouse/actions/organization-secrets` is the check). Two are visible here: `BUILDCACHE_USER` and `NEXUS_USER`. Both are org-wide shares — visible to every `diffplug` repository, not grants made to this one — and neither is referenced by any workflow in `.github/workflows/`. They are accepted rather than unshared, because narrowing them is an org-wide change to accommodate one repo. The risk is bounded: they are usernames, not the paired credentials, so alone they authenticate nothing. Any *further* org secret becoming visible here is not covered by that reasoning and must be re-evaluated — hence the FAIL IF below names these two explicitly. +**Org-level secrets.** Secrets shared with this repo from the `diffplug` org would be reachable by any workflow the bot can author, exactly like repo-level ones, and they do not appear in this repo's own secret listing (`gh api repos/diffplug/dormouse/actions/organization-secrets` is the check). None are visible here today. `BUILDCACHE_USER` and `NEXUS_USER` were org-wide shares — visible to every `diffplug` repository, not grants made to this one — and were previously accepted on the grounds that they are usernames rather than the paired credentials. They have since been narrowed to `selected` visibility over the repositories that actually consume them, which excludes this one, so the acceptance no longer has to be made. Every `diffplug` org secret is now `selected` and none lists `diffplug/dormouse`. Any org secret becoming visible here is an exposure that must be re-evaluated and named before it is accepted — hence the FAIL IF below admits none. **Upstream compromise.** Tend's action is pinned by commit SHA (`max-sixty/tend@`) in every generated workflow, so silent updates to the running setup are not possible. `uvx tend@latest` runs only at install and during nightly regen; a compromise of that path would affect the next re-run, not the in-flight workflows. -**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` since its previous successful run, opening an issue summarizing each. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. +**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. It reports the *unexplained*: two routine sources are classified and omitted, each on evidence that cannot be minted by the credential this audit exists to watch. A Renovate pin bump must carry a valid GitHub signature *and* change nothing but the ref of an already-referenced action — the signature because `%an` is free text from git config, the name-unchanged test because a pin-only diff can still repoint `actions/checkout` at an attacker's action. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, run against that commit's `.config/tend.yaml` — identity proves nothing here, since `TEND_BOT_TOKEN` is precisely the credential in question, so reproducibility is the only acceptable evidence. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. - FAIL IF the repository ruleset named `Merge access` is missing, doesn't target `~DEFAULT_BRANCH`, blocks anything other than `update`, or doesn't have admin (`RepositoryRole` actor `5`) as its sole bypass actor. - FAIL IF the repository ruleset named `Tag operations` is missing, doesn't target `~ALL` tags, doesn't block both `creation` and `update`, or doesn't have admin-only bypass. - FAIL IF `dormouse-bot` holds a permission higher than `push` on this repository. - FAIL IF `OVSX_PAT` or `VSCE_PAT` appears as a repo-level secret. They must live only in the `vscode-extension-publish` environment. -- FAIL IF any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the `Tag operations` or `Merge access` rulesets. Today this covers `vscode-extension-publish` (`v*` tag, admin-only via `Tag operations`) and `security-audit` (`main` admin-only via `Merge access`, plus `v*` tag). +- FAIL IF any GitHub environment's deployment-branch-policies admit a ref that is not admin-gated by the `Tag operations` or `Merge access` rulesets. Today this covers `vscode-extension-publish` (`v*` tag, admin-only via `Tag operations`), `security-audit` (`main` admin-only via `Merge access`, plus `v*` tag), and `tend` (`main` only, admin-only via `Merge access`). - FAIL IF `AUDIT_PAT` is missing from the `security-audit` environment, or is present at the repo level instead. The audit refuses to run without it, and it must be env-scoped so a bot-pushed feature branch cannot reach it. +- FAIL IF `TEND_BOT_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN` is missing from the `tend` environment, or appears at the repo level. Both are env-scoped so a workflow pushed to any branch the policy excludes cannot read them; a repo-level copy would reopen exactly what the environment gate closes. `CLAUDE_CODE_OAUTH_TOKEN` additionally lives in the `security-audit` environment, which reads it from inside that environment — environments do not inherit each other's secrets, so a rotation must set both. - FAIL IF `CHROMATIC_PROJECT_TOKEN` is missing from `secrets.allowed` in `.config/tend.yaml`. The allowlist entry is an explicit acknowledgment that the bot can read this token. - FAIL IF an `ANTHROPIC_API_KEY` secret is reachable at repo or org level while `tend-*.yaml` still passes `anthropic_api_key` to `max-sixty/tend/claude`. Every tend workflow already reads it, so provisioning it silently widens the bot's reach; landing it requires documenting the new secret in the reachable-secrets analysis above and amending this check. -- FAIL IF any org-level secret other than `BUILDCACHE_USER` and `NEXUS_USER` is visible to this repository. Org secrets are reachable by any workflow the bot can author but never appear in the repo-level secret listing, so each one is an accepted exposure that must be named here; those two are accepted per the analysis above. +- FAIL IF any org-level secret is visible to this repository. Org secrets are reachable by any workflow the bot can author but never appear in the repo-level secret listing, so each one would be an accepted exposure that must be named here. None is accepted today — every `diffplug` org secret is scoped to `selected` repositories that exclude this one. - FAIL IF `.github/workflows/workflow-audit.yaml` is missing, disabled, or has not produced a successful run in the last 48 hours. - FAIL IF any `tend-*.yaml` workflow uses an unpinned action reference (e.g. `@main`, no version). Tag pins are accepted inside `tend-*.yaml` because the file is owned by the upstream generator; every other workflow — agent-managed or not — must SHA-pin per the rule above. - FAIL IF any agent-managed workflow grants a permission beyond `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`, `actions: read`, or any `read` permission. From 9233c8aafde27433ac66cab105b8a418a5dadaf7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 15:47:29 -0700 Subject: [PATCH 2/7] security(workflow-audit): correct the Renovate evidence claim, guard the tend config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the stated basis for the Renovate classifier was wrong. The signature on a Renovate commit is GitHub's web-flow key (committer.login == "web-flow"), applied to anything created through the API with a caller-supplied author — it attests that GitHub made the commit, not that Renovate did. Verified on 4c2d3a4. So a token with `workflow` write could mint all three identity signals via the contents API. The content test was and remains the actual control. Says so now, in both the workflow comment and SECURITY.md, and adds PR authorship — assigned server-side, unforgeable by the caller, though a push onto an existing renovate/* branch inherits that branch's PR, so it narrows rather than proves. Also guards the tend classifier against a commit that edits .config/tend.yaml and regenerates: that reproduces byte-for-byte by construction, which would make "reproducible" contingent on the upstream generator escaping its config inputs. Probed both documented paths against 0.1.17 — bot_name is rejected by username validation and watched_workflows is correctly escaped into the workflow_run list — so this is hardening rather than a live hole, but it removes an assumption about someone else's sanitizer from a control that exists because the bot can author workflows. Third: the REFS assignment could abort the step. `git branch -a --contains` exits 0 with no output for a commit reachable only from a tag, making `grep -v` exit 1, which under `set -euo pipefail` fails the run rather than falling through to the fallback — precisely on the commit shape the fallback was written for. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/workflow-audit.yaml | 63 +++++++++++++++++++-------- SECURITY.md | 2 +- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index 96969315..af42945b 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -78,26 +78,42 @@ jobs: exit 0 fi - # A routine Renovate pin bump: GitHub-signed, and every changed line - # under .github/workflows/ is a `uses:` whose action name is unchanged. - # The signature is the load-bearing half — `.author.login` resolves - # from the commit's email, which anyone can set, so identity alone - # proves nothing. The name-unchanged test is the other half: a - # pin-only diff can still repoint `actions/checkout` at `evil/action`, - # which is a rewrite, not a bump. + # A routine Renovate pin bump: every changed line under + # .github/workflows/ is a `uses:` whose action name is unchanged. + # + # The content test is the control. None of the identity signals below + # is conclusive against a token holding `repo` + `workflow` write: + # + # - The signature on a Renovate commit is GitHub's *web-flow* key + # (`committer.login == "web-flow"`), which GitHub applies to + # anything created through its API, with the `author` on that call + # supplied by the caller. It attests that GitHub made the commit, + # not that Renovate did. + # - `.author.login` resolves from the commit's email, which is + # settable. + # - PR authorship *is* assigned server-side from the authenticated + # identity and cannot be set by the caller — but a push onto an + # existing `renovate/*` branch inherits that branch's PR. + # + # Together they narrow the field; what bounds the damage is that the + # diff can express nothing but a new ref for an already-referenced + # action. A pin-only diff can still repoint `actions/checkout` at + # `evil/action`, which is why the action name is compared. The residual + # is a ref the attacker controls *within that action's own repo* — + # the same trust Renovate bumps already rest on (see "GitHub Actions + # Policies" in SECURITY.md). is_renovate_pin_bump() { - local sha="$1" meta verified reason login diff changed removed added + local sha="$1" login pr_authors diff changed removed added - meta=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" \ - --jq '[.commit.verification.verified, .commit.verification.reason, (.author.login // "")] | @tsv') || return 1 - verified=$(echo "$meta" | cut -f1) - reason=$(echo "$meta" | cut -f2) - login=$(echo "$meta" | cut -f3) - - [ "$verified" = "true" ] || return 1 - [ "$reason" = "valid" ] || return 1 + login=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" \ + --jq '[.commit.verification.verified, (.author.login // "")] | @tsv' \ + | awk -F'\t' '$1 == "true" { print $2 }') || return 1 [ "$login" = "renovate[bot]" ] || return 1 + pr_authors=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" \ + --jq 'if length == 0 then "" else ([.[].user.login] | unique | join(",")) end') || return 1 + [ "$pr_authors" = "renovate[bot]" ] || return 1 + diff=$(git show --format='' -U0 "$sha" -- .github/workflows/) || return 1 # Content lines only — drop diff headers and hunk markers. changed=$(printf '%s\n' "$diff" \ @@ -134,6 +150,19 @@ jobs: return 1 fi + # The generator's output is only as trustworthy as its input, and + # .config/tend.yaml is outside this audit's window — a commit that + # edits it reproduces byte-for-byte by construction. Its values land + # verbatim in the generated YAML (`bot_name` inside GitHub expression + # strings in tend-mention.yaml; `watched_workflows` as the + # `on: workflow_run: workflows:` list in tend-ci-fix.yaml), so + # "reproducible" would imply "safe" only if the upstream generator + # escapes its config inputs — an assumption about someone else's code + # holding up a control that exists because the bot can author + # workflows. Real regen commits are version bumps that leave the + # config untouched, so this costs nothing. + [ -z "$(git show --name-only --pretty='' "$sha" -- .config/tend.yaml)" ] || return 1 + version=$(git show "$sha:.github/workflows/tend-review.yaml" 2>/dev/null \ | sed -nE '1s/^# Generated by tend ([0-9]+\.[0-9]+\.[0-9]+)\..*/\1/p') [ -n "$version" ] || return 1 @@ -207,7 +236,7 @@ jobs: DATE=$(git show -s --format='%ci' "$sha") REFS=$(git branch -a --contains "$sha" 2>/dev/null \ | grep -v 'HEAD ->' | head -10 \ - | sed 's/^[*[:space:]]*//' | paste -sd ', ' -) + | sed 's/^[*[:space:]]*//' | paste -sd ', ' - || true) { echo "### \`${sha:0:7}\` — $SUBJECT" echo "" diff --git a/SECURITY.md b/SECURITY.md index cc5c6a87..2936265d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,7 +59,7 @@ An attacker who lands a prompt injection in tend's harness can reach three secre **Upstream compromise.** Tend's action is pinned by commit SHA (`max-sixty/tend@`) in every generated workflow, so silent updates to the running setup are not possible. `uvx tend@latest` runs only at install and during nightly regen; a compromise of that path would affect the next re-run, not the in-flight workflows. -**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. It reports the *unexplained*: two routine sources are classified and omitted, each on evidence that cannot be minted by the credential this audit exists to watch. A Renovate pin bump must carry a valid GitHub signature *and* change nothing but the ref of an already-referenced action — the signature because `%an` is free text from git config, the name-unchanged test because a pin-only diff can still repoint `actions/checkout` at an attacker's action. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, run against that commit's `.config/tend.yaml` — identity proves nothing here, since `TEND_BOT_TOKEN` is precisely the credential in question, so reproducibility is the only acceptable evidence. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. +**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. It reports the *unexplained*: two routine sources are classified and omitted, each on evidence that cannot be minted by the credential this audit exists to watch. A Renovate pin bump must change nothing but the ref of an already-referenced action. That content test is the control, not the accompanying identity checks: the signature on a Renovate commit is GitHub's *web-flow* key, which GitHub applies to anything created through its API with a caller-supplied `author`, so it attests that GitHub made the commit rather than that Renovate did; `.author.login` resolves from a settable email; and while PR authorship is assigned server-side and cannot be forged, a push onto an existing `renovate/*` branch inherits that branch's PR. What bounds the damage is that the diff can express nothing but a new ref for an action already referenced by name — the residual being a ref the attacker controls inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. - FAIL IF the repository ruleset named `Merge access` is missing, doesn't target `~DEFAULT_BRANCH`, blocks anything other than `update`, or doesn't have admin (`RepositoryRole` actor `5`) as its sole bypass actor. - FAIL IF the repository ruleset named `Tag operations` is missing, doesn't target `~ALL` tags, doesn't block both `creation` and `update`, or doesn't have admin-only bypass. From 84003ef8042fbf1e38efc0545e826b74fc46b9ce Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 16:31:25 -0700 Subject: [PATCH 3/7] security(workflow-audit): align the file header, declare pull-requests: read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from review, both correct. The file-header comment still described the classifiers as resting on "evidence an attacker cannot mint" with the GitHub signature as the first bullet — the claim the previous commit spent twenty lines refuting further down and removed from SECURITY.md. It's the first thing a reader of this workflow sees. Now describes the content test as the control and names the .config/tend.yaml requirement the tend bullet had gained. `permissions:` never granted `pull-requests`, and declaring the block sets every unlisted scope to `none`. The PR-authorship call added in the previous commit reads /commits/{sha}/pulls, so a 403 there would make is_renovate_pin_bump fail open, report every Renovate bump, and silently undo this PR's main effect — visible only to whoever reads a nightly run summary. `pull-requests: read` is within the permission ceiling SECURITY.md sets for agent-managed workflows (any read). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/workflow-audit.yaml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index af42945b..16512819 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -10,16 +10,20 @@ name: workflow-audit # forward rather than skipping commits. # # Reports the *unexplained*. Two routine sources are classified and -# skipped, each on evidence an attacker cannot mint rather than on the -# commit's self-declared author (`%an` is free text from git config): +# skipped on the *content* of the diff, not on the commit's self-declared +# author (`%an` is free text from git config) and not on the identity +# signals that accompany a Renovate commit — none of which is conclusive +# against a token holding `repo` + `workflow` write (see the comment on +# `is_renovate_pin_bump`): # -# - Renovate pin bumps — GitHub-signed (`verification.verified`), and -# every changed line in .github/workflows/ is a `uses:` line whose -# action name is unchanged, only its ref. "Same action, new pin." +# - Renovate pin bumps — every changed line in .github/workflows/ is a +# `uses:` line whose action name is unchanged, only its ref. "Same +# action, new pin." # - tend regeneration — the changed tend-*.yaml files reproduce # byte-for-byte from `uvx tend@ init` at the version in the # files' own generated header, run against that commit's own -# .config/tend.yaml. +# .config/tend.yaml — which the commit must leave untouched, or +# "reproducible" is true by construction. # # Both classifiers fail open: any error, ambiguity, or unparseable input # reports the commit. A silent run is the healthy steady state and keeps @@ -40,6 +44,11 @@ permissions: contents: read issues: write actions: read + # `is_renovate_pin_bump` reads /commits/{sha}/pulls. Declaring a + # permissions block sets every scope not listed to `none`, and the + # classifier fails open — so without this the Renovate arm would 403, + # report every bump, and quietly undo this workflow's whole effect. + pull-requests: read jobs: audit: From 7793ec553d9bdb0f2fe53bced56c93c2fe0b2675 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 16:38:32 -0700 Subject: [PATCH 4/7] security(workflow-audit): put .config/tend.yaml in the audit window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config guard added two commits ago catches only the single-commit shape. Split across two commits it does nothing: commit A edits .config/tend.yaml and nothing else, so it never enters the audit at all — the window is `git log --all -- .github/workflows/`. Commit B then regenerates, touching only tend-*.yaml, leaves the config untouched, passes the guard, and reproduces byte-for-byte against the config A already landed. The fix is the window, not another check. .config/tend.yaml joins the pathspec in three places — the commit list and both own_changes branches — so the config edit is an auditable commit reported on its own content. Both classifiers already fail open on it: each scopes its own diff to .github/workflows/, which comes back empty for a config-only commit. A genuine regen is unaffected, and the same-commit guard still covers the combined shape. Verified by constructing the split on a real branch: the config commit is now reported with .config/tend.yaml in its file list, where before this change neither commit appeared. Also drops "signed" from the Renovate skip label, which rested on the word this PR retracted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/workflow-audit.yaml | 31 +++++++++++++++++++-------- SECURITY.md | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index 16512819..b09797ba 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -1,9 +1,13 @@ name: workflow-audit -# Nightly audit of every commit touching .github/workflows/. Surfaces -# changes from feature branches and direct pushes, not just the main -# branch — so a bot push that adds a new workflow file gets a visible -# issue even if it never opens a PR. +# Nightly audit of every commit touching .github/workflows/ or +# .config/tend.yaml. Surfaces changes from feature branches and direct +# pushes, not just the main branch — so a bot push that adds a new +# workflow file gets a visible issue even if it never opens a PR. +# +# The config is in the window because it is an input to the generated +# workflows, so an edit to it is a workflow change made one step +# earlier. # # Gap-resistant: the "since" lower bound comes from the previous # successful run's API timestamp, so a failed run pushes the window @@ -23,7 +27,8 @@ name: workflow-audit # byte-for-byte from `uvx tend@ init` at the version in the # files' own generated header, run against that commit's own # .config/tend.yaml — which the commit must leave untouched, or -# "reproducible" is true by construction. +# "reproducible" is true by construction. The config being in the +# window is what closes the same trick split across two commits. # # Both classifiers fail open: any error, ambiguity, or unparseable input # reports the commit. A silent run is the healthy steady state and keeps @@ -78,8 +83,16 @@ jobs: fi echo "Auditing commits since: $SINCE" + # .config/tend.yaml is in the window, not just .github/workflows/. + # Without it, a commit editing only the config never enters the audit, + # and a later commit regenerating from it leaves the config untouched, + # passes the same-commit guard in is_tend_regen, and reproduces + # byte-for-byte against a config nothing ever looked at. Widening the + # window makes the config edit an auditable commit reported on its own + # content — both classifiers already fail open on it, since each scopes + # its own diff to .github/workflows/ and comes back empty. COMMITS=$(git log --all --since="$SINCE" --pretty=format:'%H' \ - -- .github/workflows/ | sort -u) + -- .github/workflows/ .config/tend.yaml | sort -u) if [ -z "$COMMITS" ]; then echo "No workflow file changes since $SINCE." @@ -207,11 +220,11 @@ jobs: parents=$(git rev-list --parents -n1 "$sha" | cut -d' ' -f2-) nparents=$(printf '%s\n' "$parents" | wc -w | tr -d ' ') if [ "$nparents" -le 1 ]; then - git show --name-only --pretty='' "$sha" -- .github/workflows/ + git show --name-only --pretty='' "$sha" -- .github/workflows/ .config/tend.yaml return fi for p in $parents; do - cur=$(git diff --name-only "$p" "$sha" -- .github/workflows/ | sort -u) + cur=$(git diff --name-only "$p" "$sha" -- .github/workflows/ .config/tend.yaml | sort -u) if [ "$first" -eq 1 ]; then acc="$cur"; first=0 else @@ -232,7 +245,7 @@ jobs: fi if is_renovate_pin_bump "$sha"; then - echo "- \`${sha:0:7}\` — $SUBJECT (signed Renovate pin bump)" >> "$SKIPPED" + echo "- \`${sha:0:7}\` — $SUBJECT (Renovate pin bump: same action, new ref)" >> "$SKIPPED" continue fi if is_tend_regen "$sha"; then diff --git a/SECURITY.md b/SECURITY.md index 2936265d..5e4fb693 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,7 +59,7 @@ An attacker who lands a prompt injection in tend's harness can reach three secre **Upstream compromise.** Tend's action is pinned by commit SHA (`max-sixty/tend@`) in every generated workflow, so silent updates to the running setup are not possible. `uvx tend@latest` runs only at install and during nightly regen; a compromise of that path would affect the next re-run, not the in-flight workflows. -**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. It reports the *unexplained*: two routine sources are classified and omitted, each on evidence that cannot be minted by the credential this audit exists to watch. A Renovate pin bump must change nothing but the ref of an already-referenced action. That content test is the control, not the accompanying identity checks: the signature on a Renovate commit is GitHub's *web-flow* key, which GitHub applies to anything created through its API with a caller-supplied `author`, so it attests that GitHub made the commit rather than that Renovate did; `.author.login` resolves from a settable email; and while PR authorship is assigned server-side and cannot be forged, a push onto an existing `renovate/*` branch inherits that branch's PR. What bounds the damage is that the diff can express nothing but a new ref for an action already referenced by name — the residual being a ref the attacker controls inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. +**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` or `.config/tend.yaml` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the *unexplained*: two routine sources are classified and omitted, each on evidence that cannot be minted by the credential this audit exists to watch. A Renovate pin bump must change nothing but the ref of an already-referenced action. That content test is the control, not the accompanying identity checks: the signature on a Renovate commit is GitHub's *web-flow* key, which GitHub applies to anything created through its API with a caller-supplied `author`, so it attests that GitHub made the commit rather than that Renovate did; `.author.login` resolves from a settable email; and while PR authorship is assigned server-side and cannot be forged, a push onto an existing `renovate/*` branch inherits that branch's PR. What bounds the damage is that the diff can express nothing but a new ref for an action already referenced by name — the residual being a ref the attacker controls inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. - FAIL IF the repository ruleset named `Merge access` is missing, doesn't target `~DEFAULT_BRANCH`, blocks anything other than `update`, or doesn't have admin (`RepositoryRole` actor `5`) as its sole bypass actor. - FAIL IF the repository ruleset named `Tag operations` is missing, doesn't target `~ALL` tags, doesn't block both `creation` and `update`, or doesn't have admin-only bypass. From 4843794f1a0ed995a41c587bf3c009c2ba4507cb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 17:00:23 -0700 Subject: [PATCH 5/7] security(workflow-audit): mirror the config guard into the Renovate arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening the window in the previous commit made .config/tend.yaml reachable by both classifiers, but only is_tend_regen refuses it. So a commit that is a clean pin bump *and* edits the config classified as explained: the workflow-side diff is uses:-only with matching action names, the classifier returns 0, and own_changes — which correctly lists the config — is discarded by the continue. Same shape the previous commit closed for the regen arm, arriving through the other one. The identity checks don't bound it, by this PR's own account: the signature is web-flow, the author is caller-supplied, and PR authorship is inherited by pushing onto the existing renovate/* branch. The content test was the only bound, and its scope was narrower than the window it ran inside. Mirrors the one-line guard is_tend_regen already carries. With both arms refusing it, any commit touching the config must be reported by one of them — that pairing is the invariant, and there is no third classifier to fall out of step with the window. Verified: a clean pin bump still classifies as explained, a pin bump carrying a config edit is reported, and both genuine commits in the repo (4c2d3a4, 7c49ef6) are unaffected. Also refreshes the issue-body, log, and step-summary text, which still described the window as .github/workflows/ alone — the issue body being the one a human actually opens. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/workflow-audit.yaml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index b09797ba..77014cd0 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -22,7 +22,9 @@ name: workflow-audit # # - Renovate pin bumps — every changed line in .github/workflows/ is a # `uses:` line whose action name is unchanged, only its ref. "Same -# action, new pin." +# action, new pin." The commit must leave .config/tend.yaml alone, +# as the regen arm requires, or the config would ride along +# unexamined in a pin-shaped diff. # - tend regeneration — the changed tend-*.yaml files reproduce # byte-for-byte from `uvx tend@ init` at the version in the # files' own generated header, run against that commit's own @@ -89,14 +91,15 @@ jobs: # passes the same-commit guard in is_tend_regen, and reproduces # byte-for-byte against a config nothing ever looked at. Widening the # window makes the config edit an auditable commit reported on its own - # content — both classifiers already fail open on it, since each scopes - # its own diff to .github/workflows/ and comes back empty. + # content. Both classifiers refuse any commit that touches the config, + # so nothing in the widened window can be swallowed by an arm that + # doesn't inspect it — that pairing is the invariant, not either half. COMMITS=$(git log --all --since="$SINCE" --pretty=format:'%H' \ -- .github/workflows/ .config/tend.yaml | sort -u) if [ -z "$COMMITS" ]; then - echo "No workflow file changes since $SINCE." - echo "No workflow file changes since \`$SINCE\`." >> "$GITHUB_STEP_SUMMARY" + echo "No workflow or tend-config changes since $SINCE." + echo "No workflow or tend-config changes since \`$SINCE\`." >> "$GITHUB_STEP_SUMMARY" exit 0 fi @@ -136,6 +139,14 @@ jobs: --jq 'if length == 0 then "" else ([.[].user.login] | unique | join(",")) end') || return 1 [ "$pr_authors" = "renovate[bot]" ] || return 1 + # Same requirement as is_tend_regen, for the same reason: the content + # test below reads only .github/workflows/, so a config edit carried + # in a pin-bump-shaped commit would ride along unexamined. With both + # arms refusing it, any commit touching the config must be reported + # by one of them — the window covers two paths and neither classifier + # can silently swallow the one it doesn't inspect. + [ -z "$(git show --name-only --pretty='' "$sha" -- .config/tend.yaml)" ] || return 1 + diff=$(git show --format='' -U0 "$sha" -- .github/workflows/) || return 1 # Content lines only — drop diff headers and hunk markers. changed=$(printf '%s\n' "$diff" \ @@ -293,7 +304,7 @@ jobs: BODY=$(mktemp) { - echo "$COUNT unexplained commit(s) touching \`.github/workflows/\` since \`$SINCE\`." + echo "$COUNT unexplained commit(s) touching \`.github/workflows/\` or \`.config/tend.yaml\` since \`$SINCE\`." echo "" echo "Routine Renovate pin bumps and reproducible tend regenerations are" echo "classified and omitted — see the run summary for what was skipped." From 280632fa84f90e06568eb5e53b9a15b2b84b333b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 17:27:50 -0700 Subject: [PATCH 6/7] security(workflow-audit): verify Renovate provenance --- .github/workflows/workflow-audit.yaml | 62 +++++++++++++-------------- SECURITY.md | 2 +- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index 77014cd0..950403f2 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -14,17 +14,16 @@ name: workflow-audit # forward rather than skipping commits. # # Reports the *unexplained*. Two routine sources are classified and -# skipped on the *content* of the diff, not on the commit's self-declared -# author (`%an` is free text from git config) and not on the identity -# signals that accompany a Renovate commit — none of which is conclusive -# against a token holding `repo` + `workflow` write (see the comment on -# `is_renovate_pin_bump`): +# skipped on independently checked provenance and content (see the +# classifier comments for the trust boundary): # -# - Renovate pin bumps — every changed line in .github/workflows/ is a -# `uses:` line whose action name is unchanged, only its ref. "Same -# action, new pin." The commit must leave .config/tend.yaml alone, -# as the regen arm requires, or the config would ride along -# unexamined in a pin-shaped diff. +# - Renovate pin bumps — a valid GitHub-signed commit authored by +# Renovate and committed by `web-flow`, associated only with +# Renovate-authored PRs, where every changed line in +# .github/workflows/ is a `uses:` line whose action name is unchanged, +# only its ref. "Same action, new pin." The commit must leave +# .config/tend.yaml alone, as the regen arm requires, or the config +# would ride along unexamined in a pin-shaped diff. # - tend regeneration — the changed tend-*.yaml files reproduce # byte-for-byte from `uvx tend@ init` at the version in the # files' own generated header, run against that commit's own @@ -103,36 +102,33 @@ jobs: exit 0 fi - # A routine Renovate pin bump: every changed line under + # A routine Renovate pin bump: a GitHub-signed commit authored by + # Renovate and committed by `web-flow`, associated only with + # Renovate-authored PRs, where every changed line under # .github/workflows/ is a `uses:` whose action name is unchanged. # - # The content test is the control. None of the identity signals below - # is conclusive against a token holding `repo` + `workflow` write: + # The signed author/committer pair is the provenance control. GitHub's + # automatically signed `createCommitOnBranch` mutation binds the author + # to the authenticating credential and does not allow an author or + # committer override. The REST paths that do allow those fields to be + # supplied do not add GitHub's signature; they require the caller to + # supply one. Requiring both `author.login == "renovate[bot]"` and + # `committer.login == "web-flow"` on a valid signature therefore + # rejects a caller-supplied Renovate author as well as a commit signed + # by some other identity. Renovate-authored PR association is separate + # server-side corroboration. # - # - The signature on a Renovate commit is GitHub's *web-flow* key - # (`committer.login == "web-flow"`), which GitHub applies to - # anything created through its API, with the `author` on that call - # supplied by the caller. It attests that GitHub made the commit, - # not that Renovate did. - # - `.author.login` resolves from the commit's email, which is - # settable. - # - PR authorship *is* assigned server-side from the authenticated - # identity and cannot be set by the caller — but a push onto an - # existing `renovate/*` branch inherits that branch's PR. - # - # Together they narrow the field; what bounds the damage is that the - # diff can express nothing but a new ref for an already-referenced - # action. A pin-only diff can still repoint `actions/checkout` at - # `evil/action`, which is why the action name is compared. The residual - # is a ref the attacker controls *within that action's own repo* — - # the same trust Renovate bumps already rest on (see "GitHub Actions - # Policies" in SECURITY.md). + # The content test remains an independent bound: a pin-only diff can + # still repoint `actions/checkout` at `evil/action`, which is why the + # action name is compared. The residual is a ref selected by Renovate + # within that action's own repo — the same trust Renovate bumps already + # rest on (see "GitHub Actions Policies" in SECURITY.md). is_renovate_pin_bump() { local sha="$1" login pr_authors diff changed removed added login=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" \ - --jq '[.commit.verification.verified, (.author.login // "")] | @tsv' \ - | awk -F'\t' '$1 == "true" { print $2 }') || return 1 + --jq '[.commit.verification.verified, .commit.verification.reason, (.author.login // ""), (.committer.login // "")] | @tsv' \ + | awk -F'\t' '$1 == "true" && $2 == "valid" && $4 == "web-flow" { print $3 }') || return 1 [ "$login" = "renovate[bot]" ] || return 1 pr_authors=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" \ diff --git a/SECURITY.md b/SECURITY.md index 5e4fb693..a247404b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,7 +59,7 @@ An attacker who lands a prompt injection in tend's harness can reach three secre **Upstream compromise.** Tend's action is pinned by commit SHA (`max-sixty/tend@`) in every generated workflow, so silent updates to the running setup are not possible. `uvx tend@latest` runs only at install and during nightly regen; a compromise of that path would affect the next re-run, not the in-flight workflows. -**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` or `.config/tend.yaml` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the *unexplained*: two routine sources are classified and omitted, each on evidence that cannot be minted by the credential this audit exists to watch. A Renovate pin bump must change nothing but the ref of an already-referenced action. That content test is the control, not the accompanying identity checks: the signature on a Renovate commit is GitHub's *web-flow* key, which GitHub applies to anything created through its API with a caller-supplied `author`, so it attests that GitHub made the commit rather than that Renovate did; `.author.login` resolves from a settable email; and while PR authorship is assigned server-side and cannot be forged, a push onto an existing `renovate/*` branch inherits that branch's PR. What bounds the damage is that the diff can express nothing but a new ref for an action already referenced by name — the residual being a ref the attacker controls inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. +**Audit visibility.** `workflow-audit.yaml` is a nightly job that walks every commit touching `.github/workflows/` or `.config/tend.yaml` since its previous successful run — across all branches, not just `main`, so a workflow pushed to a feature branch is seen even though it never opens a PR. The config is in the window because its values are inputs to the generated workflows, making an edit to it a workflow change made one step earlier; keeping it out would let a config edit and a regeneration be split across two commits, the first invisible to the audit and the second reproducing byte-for-byte against it. It reports the *unexplained*: two routine sources are classified and omitted on independently checked provenance and content. A Renovate pin bump must be a valid GitHub-signed commit with `author.login == "renovate[bot]"` and `committer.login == "web-flow"`, must be associated only with Renovate-authored PRs, and must change nothing but the ref of an already-referenced action. The signed author/committer pair is the provenance control: GitHub's automatically signed `createCommitOnBranch` mutation binds the author to the authenticating credential and does not permit the caller to supply the author or committer, while REST paths that permit those fields require the caller to supply the signature; requiring `web-flow` therefore rejects both a caller-supplied Renovate author and a commit signed by another identity. PR authorship is independent server-side corroboration. The content test adds a separate bound by requiring the diff to express nothing but a new ref for an action already referenced by name — the residual being a ref selected by Renovate inside that action's own repo, which is the same trust every Renovate bump already rests on. A tend regeneration must reproduce byte-for-byte from `uvx tend@ init` at the version in the files' own header, and must not touch `.config/tend.yaml` in the same commit — the config's values land verbatim in the generated YAML, so a commit that edits it and regenerates would reproduce by construction, making "reproducible" contingent on the upstream generator escaping its inputs. Identity is not evidence here at all: `TEND_BOT_TOKEN` is precisely the credential in question. Both classifiers fail open: any error or ambiguity reports the commit. Commits already merged to `main` are still reported, because review is not proof — the social-engineering path above ends in an admin merge. Deliberately not deduplicated by branch or file set: that would let a benign change be reported once and a later force-push of malicious content to the same files pass unremarked. A silent run is the healthy steady state; the liveness check below keys on a successful run, not on an issue existing. A bot push that disables or modifies the audit itself is caught in the next successful run's diff window. - FAIL IF the repository ruleset named `Merge access` is missing, doesn't target `~DEFAULT_BRANCH`, blocks anything other than `update`, or doesn't have admin (`RepositoryRole` actor `5`) as its sole bypass actor. - FAIL IF the repository ruleset named `Tag operations` is missing, doesn't target `~ALL` tags, doesn't block both `creation` and `update`, or doesn't have admin-only bypass. From f38e4bd9cfb0d2c45732027891a7689c818de50f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 17:43:27 -0700 Subject: [PATCH 7/7] Update .github/workflows/workflow-audit.yaml Co-authored-by: dormouse-bot --- .github/workflows/workflow-audit.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/workflow-audit.yaml b/.github/workflows/workflow-audit.yaml index 950403f2..70bd6cb9 100644 --- a/.github/workflows/workflow-audit.yaml +++ b/.github/workflows/workflow-audit.yaml @@ -110,9 +110,13 @@ jobs: # The signed author/committer pair is the provenance control. GitHub's # automatically signed `createCommitOnBranch` mutation binds the author # to the authenticating credential and does not allow an author or - # committer override. The REST paths that do allow those fields to be - # supplied do not add GitHub's signature; they require the caller to - # supply one. Requiring both `author.login == "renovate[bot]"` and + # committer override. And on the REST paths that do accept those + # fields, GitHub's rule is that a bot signature is applied only when + # the request "contains no custom author information, custom + # committer information, and no custom signature information" — + # supplying an author means the commit is not signed at all, rather + # than signed by the caller. + # Requiring both `author.login == "renovate[bot]"` and # `committer.login == "web-flow"` on a valid signature therefore # rejects a caller-supplied Renovate author as well as a commit signed # by some other identity. Renovate-authored PR association is separate