Skip to content

fix(ci): vendor the archived contributor-assistant/github-action (BACKLOG #1364) - #621

Open
wshallwshall wants to merge 2 commits into
mainfrom
claude/musing-rhodes-efda3e
Open

fix(ci): vendor the archived contributor-assistant/github-action (BACKLOG #1364)#621
wshallwshall wants to merge 2 commits into
mainfrom
claude/musing-rhodes-efda3e

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

contributor-assistant/github-action, pinned in .github/workflows/cla.yml, is archived upstream. zizmor's archived-uses rule flags it. zizmor is advisory-only here, so nothing is blocked today; this is planned hardening, not an incident.

No maintained fork or successor exists. All 138 forks were checked. The hosted cla-assistant.io service is not a drop-in either, because it would drop this project's in-repo signature storage on the cla-signatures branch.

So the action's own code is vendored into .github/actions/cla-assistant-lite/ and cla.yml now points at the local path.

Lander verification of the supply-chain claim

The safety argument is that vendoring relocates an already-trusted dependency rather than adding a new one. I tested that against GitHub rather than accepting it:

our dist/index.js                       1179882 bytes
upstream @ ca4a40a7... dist/index.js    1179706 bytes   (delta 176)
after stripping our 2 disclosed header lines:
  ours     sha256 a44111084c0d4782206c04b4276292f7fec6d1f7a33525512fbeef3242079dfb
  upstream sha256 a44111084c0d4782206c04b4276292f7fec6d1f7a33525512fbeef3242079dfb

Byte-identical to the pinned upstream commit. The 176 bytes are the two lines the author disclosed adding: an SPDX identifier and a provenance comment naming the upstream commit.

Recording a wrong turn of my own, because it is the kind that reads as a finding: my first comparison tested raw byte equality and reported a mismatch. That test was mis-specified, not the claim. The author never said the file was byte-identical after adding a header; they said the payload matches. It does.

Job key and config are unchanged

cla.yml's diff is one line. The job key cla is the required status check context on main, and it is untouched, as are the triggers, permissions, with: block and allowlist.

Two gate gaps this exposed, fixed rather than worked around

.gitignore was silently dropping the vendored bundle. A blanket dist/ rule removed .github/actions/cla-assistant-lite/dist/index.js from every git add, which would have shipped a workflow pointing at a file that was never committed. Fixed with a negation scoped to /.github/actions/**/dist/. The blanket rule stands everywhere else.

The licence-header gate had no way to accept a legitimately non-AGPL vendored file. Stamping this project's AGPL identifier on someone else's Apache-2.0 file would be an affirmative misstatement, which is the thing that gate exists to catch. Fixed with VENDORED_LICENCES, a file list keyed by exact tracked path. It is not a tree exemption and not a glob: each entry still asserts an exact expected value, just not this project's own. One entry today, with tests, and the OK message now discloses the count.

A .gitattributes entry marks the bundle -text so line-ending conversion cannot drift the bytes away from the verified checksum. An eol=lf attempt was tried first and silently re-encoded about 1,300 bytes; the author caught that by re-verifying rather than assuming.

What did not run

The authoring worktree has no venv, so the full pytest and mypy suites did not run locally. Pre-commit hooks did run and pass: ruff, ledger gate, forbidden-content, licence-header, secrets, actionlint, bandit. The suites are CI's job on this PR.

Not armed

The CLA workflow gates every external contributor merge, and this changes where its code comes from. Required approvals on this repo are 0, so arming would land it unread. It should get a human read first.

BACKLOG #1364 is currently claimed by the authoring worktree. It can be released once this lands.

Generated with Claude Code

wshallwshall and others added 2 commits August 26, 2026 16:02
…KLOG #1364)

contributor-assistant/github-action, pinned in cla.yml at ca4a40a7d1004f18d9960b404b97e5f30a505a08
(v2.6.1), is archived upstream (maintainer SAP ran out of bandwidth). zizmor's archived-uses rule
flags it; zizmor is advisory-only here (paths-filtered, not in .github/required-contexts.txt), so
nothing is blocked today -- this is planned hardening, not an incident.

CHECKED FIRST. All 138 forks of the archived repo, for one with real adoption or a patch record:
none qualified (the most active, iainmcgin/cla-github-action, is 4 stars and one maintainer; the
rest are 0 stars or stale since 2020). The one non-fork alternative, the hosted cla-assistant.io
service, needs a GitHub App install and stores signatures on its own server -- cla.yml's own
comments call out in-repo signature storage specifically so no extra secret is needed, and a
hosted service gives that up. Neither is a drop-in.

THE FIX. The action is small and self-contained: 15 TypeScript files compiled to one
dist/index.js (~1.2MB), Apache-2.0 licensed, calling only the GitHub API. Vendored as
.github/actions/cla-assistant-lite/ (action.yml, dist/index.js, LICENSE, a short README naming
the upstream commit); cla.yml's uses: now points at the local path. The job key `cla` (the
required-status-check context per docs/CI.md and .github/required-contexts.txt), the on: trigger
set, permissions:, the if: condition, every with: input, and the allowlist are byte-for-byte
unchanged.

VERIFIED, six independent passes:
  - job key/triggers/permissions/if: condition unchanged
  - with: block byte-for-byte unchanged bar the uses: line
  - every input cla.yml sets exists as a declared input in the vendored action.yml
  - vendored action.yml + dist/index.js checksum-match an independent fresh fetch of the same
    pinned commit
  - Apache-2.0 LICENSE and attribution preserved
  - zizmor itself wasn't installed in this sandbox so it could not be re-run locally (CI will
    exercise it on push); the no-new-glyphs / comments-explain-why check it also carried did pass

A follow-up pattern scan of the vendored bundle (every http(s):// literal, child_process, eval(,
new Function(, exec() found nothing outside api.github.com/github.com/uploads.github.com calls and
doc-comment URLs from bundled open-source libraries; the one eval( is the known
eval("require")("encoding") idiom node-fetch uses to dodge bundler static analysis, not dynamic
execution; every exec( hit is RegExp.exec(), not a shell call. That is a pattern scan, not a full
line-by-line audit -- before the header described below was added, the vendored bytes were
checksum-identical to what has already been running in this exact workflow under the pinned SHA,
so this relocates an already-trusted dependency rather than introducing a new one.

TWO GATES CAUGHT REAL PROBLEMS ON THE FIRST COMMIT ATTEMPT, before anything landed:

  - .gitignore's blanket `dist/` rule (a Python build-artifact convention) was silently dropping
    dist/index.js from every git add. Fixed with a narrow negation in .gitignore plus a `-text`
    .gitattributes entry scoped to this one path -- `text eol=lf` was tried first and re-encoded
    ~1,300 bytes on staging, which would have committed a file that no longer matched its own
    recorded checksum.
  - The licence-header gate (scripts/quality/licence_header_check.py) had no way to accept a file
    that legitimately isn't AGPL-3.0. Forcing this project's licence onto someone else's Apache-2.0
    code is exactly the "affirmative misstatement of licence" that gate's own docstring says is
    worse than an omission. Fixed with a narrow, named VENDORED_LICENCES override (one file, one
    entry, still asserting an exact value -- not a tree exemption) plus a two-line
    `// SPDX-License-Identifier: Apache-2.0` + provenance comment at the top of the vendored file.
    That comment is the only byte difference from the pure upstream copy: stripping the first 176
    bytes reproduces the original verified checksum
    (a44111084c0d4782206c04b4276292f7fec6d1f7a33525512fbeef3242079dfb) exactly; the as-committed
    file's own checksum is 2f8271c83b98e86b8b203205c0dda08415fa49c8c300921e481aabf5c9b989f8.
    tests/test_licence_header_gate.py gained four cases covering the override mechanism itself
    (unregistered path still uses the project licence, registered path uses its own, the override
    still rejects a wrong value rather than skipping the file outright, and the one real production
    entry is checked end to end).

VERIFIED (this worktree has no venv, so full pytest/ruff/mypy did not run):
  - scripts/quality/licence_header_check.py run directly against the real tracked tree: OK, 1353
    files checked, 0 violations (the vendored file included)
  - the new VENDORED_LICENCES logic manually exercised standalone (no pytest) against all four
    cases the added tests cover: unregistered-path, registered-and-compliant,
    registered-but-wrong-value, and the real production entry -- all behaved as intended
  - NOT run: the full pytest suite, ruff, mypy. CI will run all three on push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: hold this. Three defects found by an adversarial review, none of them the CodeQL alert.

I verified the supply chain on this PR thoroughly and signed off. That verification was real but it answered the wrong question: I checked whether the vendored bytes were trustworthy and never checked whether the workflow still runs. An adversarial pass found three things, and I have since confirmed each by direct measurement.

1. The cla job has no checkout, and cla is a required context

.github/workflows/cla.yml -- every `uses:` in the file:
  50:  uses: ./.github/actions/cla-assistant-lite

.github/required-contexts.txt:95
  cla

A local uses: ./path action resolves against $GITHUB_WORKSPACE. Nothing checks the repository out, so after this merge the action has no files to load. cla is a required status check, so the failure would not be confined to this PR. It would wedge every later pull request on a required context.

2. A zizmor suppression whose stated premise goes false on merge

.github/zizmor.yml suppresses dangerous-triggers for this workflow, and states the ground in its own comment:

The action is pinned at ca4a40a7d100 (v2.6.1)

and, contrasting cla.yml against a stricter neighbour:

cla.yml runs a third-party action with a write token -- SHA-pinned, so it cannot change under us

After this merge it is not SHA-pinned. It is a local path. The suppression survives with its reason removed, which is the shape CLAUDE.md section 11 forbids by name (SDS-3.7, a compensating control resting on a false premise). git show 0c3cd3d57 --name-only confirms .github/zizmor.yml is untouched by this PR.

3. A dead line anchor

The archived-uses ignore is anchored at cla.yml:44. The uses: line is now at line 50. The entry's own comment says "Re-anchor; do not broaden."

And one thing that is not a defect but changes how to read this PR's own checks

PR 621's green cla check ran main's external action, not the vendored path. The vendored code has never executed. So the green check on this PR is not evidence that the vendored configuration works, and it cannot be, because the workflow that would exercise it is the one being changed.

What the CodeQL alert turned out to be

The js/regex/missing-regexp-anchor alert at dist/index.js:4608 is real but not exploitable here. The regex chooses only between response.text() and response.arrayBuffer() on a content-type header from api.github.com, which no fork contributor can set or redirect. A reviewer found a sharper bypass string than the alert describes and traced all four consumers of a wrongly-unparsed body; every one throws before any CLA decision, and getCommitters rethrows rather than swallowing, so there is no route to a fail-open green check. It fails closed everywhere reachable. CodeQL is not a required context in any case.

One correction to my own reasoning in the PR body

I wrote that byte-identity means vendoring "relocates an already-trusted dependency rather than adding a new one". The premise is true and the conclusion is stronger than it supports. A SHA pin is re-verified by GitHub on every run; a vendored blob is verified once and then trusted. The recorded checksum currently lives only in prose, and no workflow, script or test re-verifies the bundle. Separately, @octokit/request and its dependency tree enter this repository with no software-composition-analysis coverage: there is no package.json beside the bundle, dependabot.yml's github-actions ecosystem does not track uses: ./path, and npm-audit runs under working-directory: ide. That gap is created by this PR.

Recommendation

Keep unarmed. Items 1 and 3 are small and mechanical. Item 2 is a security-prose decision about what the suppression should now rest on, and the checkout fix has a trap worth stating out loud: a bare actions/checkout is correct here, but dependabot-lock-resync.yml:112 contains ref: ${{ github.event.pull_request.head.sha }}, which is safe there and would be seriously wrong here. Under pull_request_target with write permissions, that line plus uses: ./ would have the runner execute fork-authored content.

The authoring session has ended, so this is mine to carry unless the owner would rather route it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant