SEC2-033: The monthly dependency workflow runs freshly resolved packages as root in the container that later holds the bot's write token #975
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#975
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-10-16 in a read-only security review of
postuloat56fc9d531, 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 syncandpytestagainst the new set).forgejo/workflows/dependencies.yml:98-158(TOKEN: ${{ secrets.DEPENDENCIES_TOKEN || secrets.GITHUB_TOKEN }}, thengit 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-bookwormcontainer, 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 --upgradebuilds any sdist it needs for metadata,uv syncinstalls the result, anduv run pytestloads everypytest11entry 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 thePATH, and the final step then runs them withTOKENin its environment:DEPENDENCIES_TOKENis a token ofpostulo-botwith write access to code and pull requests on this repository (the workflow's own comment; introduced for #585). The fallbacksecrets.GITHUB_TOKENhas 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 thatmainhas 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
mainwhile 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:devimage 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:
uv.lockand.pre-commit-config.yamlas an artifact (actions/upload-artifact@v3works on this Forgejo);needs:on the first, starts from a fresh container (fresh checkout,$HOMEandPATH; nothing from job one), downloads the two files, runs nouv, nopytestand nothing from the new packages, and does thegit commit,git pushand API call with the token;git -c core.hooksPath=/dev/null commit ...and... push ..., which a planted~/.gitconfigcannot override) and assertgit status --porcelainnames only the two files before committing.Done when: a test in
tests/test_tooling.py(which already reads tooling configuration) readsdependencies.ymland asserts the job that referencesDEPENDENCIES_TOKENhas no step runninguv sync,uv lockorpytest, and that its git commands carrycore.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 formain, which limits the blast radius but does not remove the token exposure.