SEC2-033: The monthly dependency workflow runs freshly resolved packages as root in the container that later holds the bot's write token #975

Closed
opened 2026-10-17 10:38:54 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Medium · Category: Configuration · Reference: CWE-829 (Inclusion of Functionality from Untrusted Control Sphere); CWE-250 (Execution with Unnecessary Privileges); OWASP A08:2021 (Software and Data Integrity Failures)

Location:

  • .forgejo/workflows/dependencies.yml:62-77 (uv lock --upgrade, which may build sdists)
  • .forgejo/workflows/dependencies.yml:79-96 (uv sync and pytest against the new set)
  • .forgejo/workflows/dependencies.yml:98-158 (TOKEN: ${{ secrets.DEPENDENCIES_TOKEN || secrets.GITHUB_TOKEN }}, then git commit, git push, python3 -)

Issue

The workflow deliberately keeps the token out of the test step ("Through the environment, never expanded into the script") and only hands it to the final step. But all steps run in one node:22-bookworm container, as root, on one filesystem. Before the token appears, the job has already executed code from the newest release of every one of the roughly two hundred locked packages: uv lock --upgrade builds any sdist it needs for metadata, uv sync installs the result, and uv run pytest loads every pytest11 entry point an installed package registers before a single test runs. A step boundary is not a privilege boundary: code that ran in an earlier step can leave git hooks in the checkout or replace binaries on the PATH, and the final step then runs them with TOKEN in its environment:

      - name: Push the branch and open the pull request
        env:
          TOKEN: ${{ secrets.DEPENDENCIES_TOKEN || secrets.GITHUB_TOKEN }}
        run: |
          git commit -m "Upgrade the lock file ..."
          git push --force "...x-access-token:${TOKEN}@..."
          python3 - "$branch" <<'PY'

DEPENDENCIES_TOKEN is a token of postulo-bot with write access to code and pull requests on this repository (the workflow's own comment; introduced for #585). The fallback secrets.GITHUB_TOKEN has the same shape on Forgejo (write access to the repository; permissions: is ignored, #727), so the exposure exists whether or not the bot secret is set. #940 (open) records that main has no branch protection yet.

Impact

A compromised upstream release of any transitive dependency, published in the month before the run, gains a token that can push to any branch of the public repository, including main while it is unprotected, and open or alter pull requests as the bot. That is a route from "a package on PyPI was taken over" to "the Postulo source that operators pull and the :dev image is built from" without a human in between, the supply-chain path #790 was filed to narrow. The same job also decides which lock to propose, so the review a person later gives the pull request is the only remaining control.

Fix

Separate the two trust levels into two jobs, with nothing but the lock file crossing between them:

  • job one resolves, installs and tests, then uploads uv.lock and .pre-commit-config.yaml as an artifact (actions/upload-artifact@v3 works on this Forgejo);
  • job two, with needs: on the first, starts from a fresh container (fresh checkout, $HOME and PATH; nothing from job one), downloads the two files, runs no uv, no pytest and nothing from the new packages, and does the git commit, git push and API call with the token;
  • in job two, run git with hooks disabled on the command line (git -c core.hooksPath=/dev/null commit ... and ... push ..., which a planted ~/.gitconfig cannot override) and assert git status --porcelain names only the two files before committing.

Done when: a test in tests/test_tooling.py (which already reads tooling configuration) reads dependencies.yml and asserts the job that references DEPENDENCIES_TOKEN has no step running uv sync, uv lock or pytest, and that its git commands carry core.hooksPath.

#790 (closed) - names this surface in its "Also covered" list as Informational, judging it safe because the token is scoped to the last step; that verdict rests on a step boundary that is not a privilege boundary. #585 (closed) - introduced the bot's write token. #727 - permissions: ignored on Forgejo. #940 (open) - branch protection for main, which limits the blast radius but does not remove the token exposure.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Medium · **Category:** Configuration · **Reference:** CWE-829 (Inclusion of Functionality from Untrusted Control Sphere); CWE-250 (Execution with Unnecessary Privileges); OWASP A08:2021 (Software and Data Integrity Failures) **Location:** - `.forgejo/workflows/dependencies.yml:62-77` (`uv lock --upgrade`, which may build sdists) - `.forgejo/workflows/dependencies.yml:79-96` (`uv sync` and `pytest` against the new set) - `.forgejo/workflows/dependencies.yml:98-158` (`TOKEN: ${{ secrets.DEPENDENCIES_TOKEN || secrets.GITHUB_TOKEN }}`, then `git commit`, `git push`, `python3 -`) ## Issue The workflow deliberately keeps the token out of the test step ("Through the environment, never expanded into the script") and only hands it to the final step. But all steps run in one `node:22-bookworm` container, as root, on one filesystem. Before the token appears, the job has already executed code from the newest release of every one of the roughly two hundred locked packages: `uv lock --upgrade` builds any sdist it needs for metadata, `uv sync` installs the result, and `uv run pytest` loads every `pytest11` entry point an installed package registers before a single test runs. A step boundary is not a privilege boundary: code that ran in an earlier step can leave git hooks in the checkout or replace binaries on the `PATH`, and the final step then runs them with `TOKEN` in its environment: ```yaml - name: Push the branch and open the pull request env: TOKEN: ${{ secrets.DEPENDENCIES_TOKEN || secrets.GITHUB_TOKEN }} run: | git commit -m "Upgrade the lock file ..." git push --force "...x-access-token:${TOKEN}@..." python3 - "$branch" <<'PY' ``` `DEPENDENCIES_TOKEN` is a token of `postulo-bot` with write access to code and pull requests on this repository (the workflow's own comment; introduced for #585). The fallback `secrets.GITHUB_TOKEN` has the same shape on Forgejo (write access to the repository; `permissions:` is ignored, #727), so the exposure exists whether or not the bot secret is set. #940 (open) records that `main` has no branch protection yet. ## Impact A compromised upstream release of any transitive dependency, published in the month before the run, gains a token that can push to any branch of the public repository, including `main` while it is unprotected, and open or alter pull requests as the bot. That is a route from "a package on PyPI was taken over" to "the Postulo source that operators pull and the `:dev` image is built from" without a human in between, the supply-chain path #790 was filed to narrow. The same job also decides which lock to propose, so the review a person later gives the pull request is the only remaining control. ## Fix Separate the two trust levels into two jobs, with nothing but the lock file crossing between them: - job one resolves, installs and tests, then uploads `uv.lock` and `.pre-commit-config.yaml` as an artifact (`actions/upload-artifact@v3` works on this Forgejo); - job two, with `needs:` on the first, starts from a fresh container (fresh checkout, `$HOME` and `PATH`; nothing from job one), downloads the two files, runs no `uv`, no `pytest` and nothing from the new packages, and does the `git commit`, `git push` and API call with the token; - in job two, run git with hooks disabled on the command line (`git -c core.hooksPath=/dev/null commit ...` and `... push ...`, which a planted `~/.gitconfig` cannot override) and assert `git status --porcelain` names only the two files before committing. Done when: a test in `tests/test_tooling.py` (which already reads tooling configuration) reads `dependencies.yml` and asserts the job that references `DEPENDENCIES_TOKEN` has no step running `uv sync`, `uv lock` or `pytest`, and that its git commands carry `core.hooksPath`. ## Related #790 (closed) - names this surface in its "Also covered" list as Informational, judging it safe because the token is scoped to the last step; that verdict rests on a step boundary that is not a privilege boundary. #585 (closed) - introduced the bot's write token. #727 - `permissions:` ignored on Forgejo. #940 (open) - branch protection for `main`, which limits the blast radius but does not remove the token exposure.
tiagoagueda added this to the 0.5.0 milestone 2026-10-17 10:38:56 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#975
No description provided.