ECLI-012: Privileged workflows execute branch-controlled npm lifecycle code
- Dominant language
- TypeScript
- Stars
- 41
- Forks
- 24
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 56
Description
**Severity: Medium**
### Problem
[.github/workflows/regenerate-notice.yml:21–57](https://github.com/elastic/cli/blob/main/.github/workflows/regenerate-notice.yml#L21-L57) and [.github/workflows/schema-auto-fix.yml:17–71](https://github.com/elastic/cli/blob/main/.github/workflows/schema-auto-fix.yml#L17-L71) both use write-capable tokens. The former grants `contents: write`; the latter grants `contents: write` and `pull-requests: write`. Both check out branch or PR content and run `npm ci`, which can execute the root `prepare` script and dependency lifecycle hooks. `schema-auto-fix.yml` also invokes the branch-controlled `build` and `build:schema` scripts. Modified branch code therefore runs while a write token is available.
Both workflows have access controls:
- `regenerate-notice.yml` blocks fork PRs via `github.event.pull_request.head.repo.full_name == github.repository`.
- `schema-auto-fix.yml` requires `MEMBER`/`OWNER`/`COLLABORATOR` author association and rejects cross-repository PRs.
The exposure is limited to authorized collaborators and compromised internal branches, not arbitrary external contributors. That lowers the severity relative to an unguarded `pull_request_target`, but a compromised collaborator account or bad dependency merge can still execute code with write privileges.
### Fix
Use different remediations for the two workflows. The required security property is that PR-controlled code never executes in a job with write permissions or secrets.
**`regenerate-notice.yml`: remove PR auto-commit**
The repository already runs `npm run test:notice` in read-only CI. Remove the write-capable `pull_request` path from `regenerate-notice.yml` and require contributors or dependency-update automation to run `npm run generate:notice` and commit `NOTICE.txt` when that check fails.
If automatic PR updates are required, use two separate workflows:
1. A `pull_request` workflow with `contents: read`, no secrets, and `persist-credentials: false` generates and uploads `NOTICE.txt`.
2. A privileged `workflow_run` workflow, whose definition comes from the default branch, downloads the artifact from the exact triggering run and commits it.
Do not put the privileged writer in the same PR-controlled workflow. The writer must verify that the triggering workflow succeeded, the PR is same-repository, and the PR head still equals the SHA used to generate the artifact.
**`schema-auto-fix.yml`: split generation and commit jobs**
Because `issue_comment` uses the workflow definition from the default branch, this workflow can safely use two jobs on separate fresh runners:
1. `generate-schema`:
- `permissions: contents: read, pull-requests: read`
- No secrets.
- Resolve and record the exact PR `headRefOid`; reject cross-repository PRs.
- Check out that SHA with `persist-credentials: false`.
- Upload only `docs/cli/schema.json` using a SHA-pinned `upload-artifact`.
2. `commit-schema`:
- `needs: generate-schema`
- `permissions: contents: write, pull-requests: write`
- Run on a fresh runner and do not invoke `npm`, `node`, or any PR-controlled script.
- Query the PR again and fail if its current head differs from the generation SHA.
- Check out the exact SHA using the write-capable token. Credential persistence is acceptable only in this isolated writer job because it executes no PR-controlled code; alternatively, perform the commit through an explicitly authenticated GitHub API operation.
- Require a regular JSON file, enforce a size limit, and validate it with `jq -e`.
- Copy it to the fixed `docs/cli/schema.json` destination.
- Assert that this is the only changed path.
- Commit and push using `--force-with-lease` against the validated SHA.
Add `npm ci --ignore-scripts` to unprivileged generation jobs where compatible. This is defense in depth: it does not stop explicitly invoked PR-controlled scripts such as `build:schema`. The primary control is running those scripts without write permissions or secrets.
### Risk
**Medium.** Current guards keep this outside the external-contributor boundary. The remaining exposure is to trusted collaborators and compromised internal branches.
---
Copied from the [security review](https://github.com/elastic/infosec/issues/27626#issuecomment-5172341916) in elastic/infosec#27626 (ECLI-012).
Contributor guide
Assessment
This issue has not been assessed yet.