Skip to content

Harden registry CI pipeline against contributor-controlled names and symlinks - #470

Merged
jeffreyaven merged 1 commit into
devfrom
feature/provider-updates
Sep 30, 2026
Merged

jeffreyaven merged 1 commit into
devfrom
feature/provider-updates

Conversation

@jeffreyaven

Copy link
Copy Markdown
Member

Summary

Hardens the post-merge build and deploy pipeline (.github/workflows/main.yml and the scripts it runs) so that contributor-controlled input can no longer reach a shell or be dereferenced by the signing step. Reported by Kevin Backhouse of the GitHub Security Lab.

Command injection via $GITHUB_ENV writes and cp (CWE-78)

  • scripts/setup-js/setup-job.js and get-version.js: replaced exec('echo "NAME=value" >> $GITHUB_ENV') with core.exportVariable(), which appends to $GITHUB_ENV using a heredoc delimiter and never invokes a shell. Branch names, PR titles and commit messages now land in the environment as literal strings. On push events the PR number is parsed with an anchored regex for GitHub's merge and squash subject formats and must be numeric; anything else fails the job.
  • scripts/setup/get-updated-providers.py: os.system("echo '...' >> $GITHUB_ENV") replaced with a direct heredoc write. Every path under providers/src in the diff must be <provider>/<version>/provider.yaml or <provider>/<version>/services/<file>, with each component matching [A-Za-z0-9_][A-Za-z0-9._-]*. Git C-quoted paths are rejected.
  • scripts/package/sign-provider-docs.py: os.system("cp ...") replaced with shutil.copyfile. sign-file.sh quotes its arguments.

Symlink dereference in signing and packaging (CWE-59 / CWE-61)

  • New scripts/common/provider_tree.py with validate_provider_source_tree(): walks providers/src/<provider>/<version> one component at a time, refuses any symlink or non-regular file, refuses anything that resolves outside the tree, and refuses entries other than provider.yaml and services/. It runs in the setup step (before any secret is in the environment) and again in update-versions.py and sign-provider-docs.py.
  • scripts/tests/simulate-REGISTRY-PULL.py: tarfile.extractall(..., filter='data').
  • scripts/deploy/pull-additional-docs-from-artifact-repo.py: artifact repo keys are validated as <REG_PROVIDER_PATH>/<provider>/<file> before being joined onto a local path.
  • package-provider-docs.py and publish-provider-docs-to-artifact-repo.py: names validated before use in paths and object keys.

Other

  • main.yml: quoted $provider and $providersdir in the test step.
  • CONTRIBUTING.md: documented the layout and naming rules the pipeline now enforces.

Verification

  • All 38 existing provider directories (1909 files) pass the new validator unchanged; a full-tree run of get-updated-providers.py selects all 38 as before.
  • A local harness replays the reported proofs of concept against the fixed scripts: branch name feat.$(id>pwned), provider dir x'$(id>pwned)', service file a;id>pwned;.yaml, a symlinked service document, a symlinked provider.yaml, a symlinked services/ directory, a non-numeric PR number, and an archive with traversal and symlink members. Each is rejected with exit 1 and no command runs. Control cases produce the same output as before.

Notes for reviewers

  • REG_SOURCE_BRANCH and the other REG_* values remain contributor controlled. They are safe as environment variables but must not be interpolated with ${{ env.* }} inside run: scripts.
  • A possible follow-up is moving the sign and publish steps into a separate job that does not consume PR-derived strings, as suggested in the report.

🤖 Generated with Claude Code

Contributor-controlled data (branch names, PR titles, provider directory
and service file names) reached /bin/sh via exec/os.system in the setup
and signing scripts, and symlinks under providers/src were dereferenced
by the update, sign and package steps of the post-merge build.

- setup-job.js / get-version.js: write REG_* via core.exportVariable,
  parse the PR number with an anchored regex for GitHub merge and squash
  subjects and require it to be numeric
- get-updated-providers.py: write PROVIDERS / NUM_PROVIDERS directly to
  $GITHUB_ENV in heredoc form; accept only
  providers/src/<provider>/<version>/{provider.yaml,services/<file>}
  with every component matching [A-Za-z0-9_][A-Za-z0-9._-]*
- new scripts/common/provider_tree.py: shared name allowlist, symlink /
  regular-file / containment checks, GITHUB_ENV writer, S3 key parser
- update-versions.py, sign-provider-docs.py: validate the provider tree
  before reading it; shutil.copyfile instead of os.system("cp ...")
- simulate-REGISTRY-PULL.py: tarfile extractall with filter='data'
- pull-additional-docs-from-artifact-repo.py: validate artifact keys
  before joining them onto local paths
- sign-file.sh, main.yml: quote arguments
- CONTRIBUTING.md: document the enforced layout and naming rules

All 38 existing providers (1909 files) pass the new checks unchanged.

Reported by Kevin Backhouse (GitHub Security Lab).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jeffreyaven jeffreyaven self-assigned this Sep 30, 2026
@jeffreyaven
jeffreyaven merged commit 53ec71d into dev Sep 30, 2026
12 checks passed
jeffreyaven added a commit that referenced this pull request Sep 30, 2026
Promote CI pipeline hardening to main (#470)
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