ci: harden the cloud test path and pin the remaining floating actions - #1975
ci: harden the cloud test path and pin the remaining floating actions#1975thomasboni wants to merge 4 commits into
Conversation
| profile: pr-compare | ||
| pr_number: ${{ github.event.issue.number || inputs.pr_number }} | ||
| secrets: inherit | ||
| secrets: |
There was a problem hiding this comment.
why not inherit the secrets here?
There was a problem hiding this comment.
secrets: inherit forwards every secret into the workflow, while the callee here only declares WORKFLOW_AUTH_PUBLIC_APP_ID and WORKFLOW_AUTH_PUBLIC_PRIVATE_KEY, so everything beyond those two is exposure with no benefit.
And this particular callee lives in a different repo (ClickHouse/integrations-shared-workflows) and is referenced on @main, meaning any future change to that workflow immediately runs here with whatever secrets we hand it.
Passing them explicitly caps what a change on that side can ever reach, and doubles as documentation of what the workflow actually needs. If the shared workflow grows a new secret later, the failure is loud (a missing-secret error) instead of a silent broadening of access.
|
|
||
| - name: golangci-lint | ||
| uses: golangci/golangci-lint-action@v9 | ||
| uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 |
There was a problem hiding this comment.
I'm not super comfortable with git commit hash here. Can't we just do @v9.3.0 here? (similar in other places?)
There was a problem hiding this comment.
In most cases the git commit hash is the only way to be sure the action you are using is immutable. It's highly recommended when the action comes from an external source that could be compromised and run malicious code in your pipeline (which has access to secrets, code, envs, etc.). That's the route the tj-actions/changed-files compromise took.
But in this specific case with golangci/golangci-lint-action you are right to prefer @v9.3.0, because the repo uses GitHub's immutable releases. The v9.3.0 release is marked immutable, meaning the tag can never be repointed or deleted. So @v9.3.0 gives the same guarantee as the SHA, and I'm going to switch that one back. Same for codeql-action, its releases are immutable too.
The others are different:
lychee-action@v2.9.0: mutable tag from a third-party org. I'd keep the SHA here, but if you prefer the tag for readability I can revert it.claude-code-action@v1.0.190: mutable tag, maintained by Anthropic. Larger vendor, but still a third party with a repointable tag. Your call, tell me which way you want it.actions/checkout@v5.1.0: mutable tag, maintained by GitHub itself. If you trust GitHub the tag is reasonable, I will revert to@v5.1.0(tell me if not).
| # access even though it executes fork code. | ||
| remove-label: | ||
| needs: [test] | ||
| if: always() && github.event_name == 'pull_request_target' |
There was a problem hiding this comment.
always() overrides the skipped test dependency, so this job runs on every labeled event on every PR — the old step only ran when the trigger label was tests:run-cloud, because it lived inside test behind that job's if gate.
- Adding any other label (e.g.
review:claude-agent) while a cloud run is in flight deletestests:run-cloudmid-run. - Every unrelated label event now spawns a runner job just to attempt a no-op DELETE.
| if: always() && github.event_name == 'pull_request_target' | |
| if: always() && github.event_name == 'pull_request_target' && github.event.label.name == 'tests:run-cloud' |
There was a problem hiding this comment.
Right, always() was meant to guarantee cleanup when the test job fails but as written it also fires for unrelated labels. Restored the label guard on the job condition.
| @@ -35,6 +36,9 @@ jobs: | |||
| uses: actions/checkout@v4 | |||
There was a problem hiding this comment.
This job runs untrusted fork code next to the cloud credentials on a self-hosted runner — the highest-value target in the repo — yet its own actions (actions/checkout@v4, actions/setup-go@v5) stay on mutable tags while lower-risk workflows got SHA pins. Tag repointing is exactly the vector the rest of this PR defends against.
- The same applies to
actions/checkout@v5incheck-pr-links.yml,actions/checkout@v4in the other test workflows, andactions/create-github-app-token@v1inrelease.yml, which handles the App private key. - GitHub-owned actions are lower-risk, but if tag trust were sufficient the five newly pinned refs could have kept their tags too — one pinning policy, applied consistently, is easier to audit.
There was a problem hiding this comment.
I disagree with the bot here: even if it doesn't look consistent, we should keep the easy human-readable tag except for risky inclusion where we have to use the SHA.
🤖 Claude reviewHardens the CI workflows: four Verified against the workflow files:
Key concerns:
Blind spots: no network access from the review environment, so I could not (a) resolve the five pinned SHAs to their claimed version tags or (b) fetch Verdict: General findings
Inline comments are attached to the relevant lines. This summary updates in place on re-review. |
|
Hi @kavirajk
|
bec40cb to
7a7056c
Compare
run-tests-cloud.yml grants write-all at workflow level and, under pull_request_target, checks out the head SHA of the pull request. The job that executes that fork code therefore holds a full write token, the cloud credentials, and a self-hosted runner. claude-code-review.yml already carries a comment warning against exactly this combination. The label gate means a maintainer has to opt a PR in, which is the right control and stays. What changes is what the opted-in code can reach: - the workflow default drops from write-all to contents: read - the only step that needed a write scope, removing the trigger label, moves to its own job on a hosted runner that never checks out the PR, guarded to its own trigger label so unrelated label events do not fire it - the checkout stops persisting the git credential, so the fork tree cannot reuse it Behaviour is unchanged: the label is still removed after every tests:run-cloud run, including failures.
Three more workflows default to write-all, and none of them uses the GITHUB_TOKEN for anything that writes: - run-tests.yml and run-tests-head.yml check out and run tests, with no API calls in either file - release.yml mints a GitHub App token and uses it for the checkout, the release API call and the push, so the workflow token is unused contents: read covers what each one actually does. release.yml keeps read rather than an empty block because setup-go resolves versions through the token. check-pr-links.yml was the one workflow without a permissions block at all, so its token fell back to the repository default. It checks out the tree and runs a link checker, with no API calls, so it gets contents: read too.
Five action references resolved at run time rather than to a fixed revision, so the code they run could change without a commit here. Two are worth calling out. The CodeQL steps referenced v1, a tag that last moved in January 2023, so the analysis has been running a three-year-old revision of the action. They move to v4.37.6. And two checkout steps referenced @main, which tracks a moving branch rather than a release. The pinning style follows what each tag actually guarantees: - golangci-lint-action and codeql-action publish immutable releases, so their release tags can never be repointed or deleted and the readable version tag is enough - actions/checkout moves to the v5.1.0 tag, maintained by GitHub itself - lychee-action and claude-code-action are third-party repos with mutable tags, so those two are pinned to the commit SHA with the version in a trailing comment Left alone deliberately: the two ClickHouse/integrations-shared-workflows references. They are your own shared workflows and pinning them to a commit would stop central updates reaching this repo, which is presumably the point of keeping them there. Happy to pin those too if you would rather have them fixed.
The benchmark job passes secrets: inherit, which hands the called workflow every secret in the repository. clickhouse-benchmark.yml declares exactly two, WORKFLOW_AUTH_PUBLIC_APP_ID and WORKFLOW_AUTH_PUBLIC_PRIVATE_KEY, both required. Passing those two explicitly keeps the cloud test credentials and the rest of the secret store out of a workflow that does not ask for them, and means a secret added here later is not forwarded by default.
7a7056c to
c73ff30
Compare
Summary
Five focused changes to the workflows, each in its own commit. No test or documentation impact.
1. The cloud test job runs pull request code with a write-all token (
run-tests-cloud.yml)claude-code-review.ymlalready carries this comment:run-tests-cloud.ymldoes that. Underpull_request_targetit checks out the head SHA, and the workflow grantswrite-all, so the job that executes fork code holds a full write token alongside the cloud credentials, on a self-hosted runner.The label gate is the right control and stays untouched. What changes is what the opted-in code can reach: the workflow default drops to
contents: read, the one step that needed a write scope (removing the trigger label) moves to its own job on a hosted runner that never checks out the PR, and the checkout stops persisting the git credential. Behaviour is unchanged, the label is still removed after every run including failures.This does not remove the underlying
pull_request_targetplus head-checkout pattern, because that is what makes cloud testing of a PR work at all. Splitting the credential-bearing part from the fork-code part would be the full fix, and it is your architectural call rather than something to slip into this PR. Happy to open a follow-up if you want it.2. Three more workflows default to
write-all(run-tests.yml,run-tests-head.yml,release.yml)None of them uses
GITHUB_TOKENfor anything that writes. The two test workflows check out and run tests with no API calls, andrelease.ymlmints a GitHub App token and uses it for the checkout, the release API call and the push, so the workflow token is unused. All three becomecontents: read.3. Five action references were not pinned
lycheeverse/lychee-action@v2,anthropics/claude-code-action@v1,golangci/golangci-lint-action@v9and twoactions/checkout@mainreferences now pin a commit with the version in a trailing comment. Two are worth calling out: the CodeQL steps were onv1, a tag that last moved in January 2023, so the analysis has been running a three-year-old revision of the action. They move to v4.37.6. Andactions/checkout@maintracks a moving branch rather than a release.Left alone deliberately: the two
ClickHouse/integrations-shared-workflowsreferences. They are your own shared workflows and pinning them would stop central updates reaching this repo, which is presumably the point. Same reasoning for theclickhouse/docs-builderimage. Happy to pin either if you would rather have them fixed.4. The link checker had no
permissions:block, so its token fell back to the repository default. It checks out and runs a link checker with no API calls, socontents: read.5. The benchmark job passed
secrets: inherit(stresshouse-benchmark-compare.yml), handing the called workflow every secret in the repository.clickhouse-benchmark.ymldeclares exactly two,WORKFLOW_AUTH_PUBLIC_APP_IDandWORKFLOW_AUTH_PUBLIC_PRIVATE_KEY. Passing those explicitly keeps the cloud credentials out of a workflow that does not ask for them, and means a secret added later is not forwarded by default.One thing worth knowing if you use a repository-level Actions allowlist: entries written as tag patterns (
owner/action@v1) stop matching once a workflow pins a SHA, and every workflow using that action then fails at startup. The fix isowner/action@*in settings.Found and fixed by Plumber's analysis, reviewed and submitted by me.