Skip to content
npm-auto-build

Menu

Type to search the docs.

↑ ↓ move · Enter open · Esc close

Threat model

This file describes what can go wrong with the action, what protects against it today (v2), and how the v3 design (#10) changes it.

  • v2 is the Docker action on main at commit f82047c. Line references such as entrypoint.sh:30, and the README.md references such as README.md:551, point to that commit (README.md at f82047c, not the current file). Re-check them when the files change.
  • v3 design names the issue that builds the control. Nothing here is released.
  • The accepted risk column is a proposal for the maintainer to confirm. It is not a decision yet.

Assets

AssetWhere it lives in v2Why it matters
The write tokenRead from INPUT_GITHUB_TOKEN (entrypoint.sh:30), written to the global git config (:141) and to the gh login (:144)It has whatever scopes the workflow grants (action.yml:23-25). With contents: write it can push to any branch the workflow allows.
The repository historyCommits (entrypoint.sh:215), pushes (:258), and PR branches (:206, :224)A bad commit lands on the branch that the workflow runs on.
The built outputBUILD_DIR (:97-113), staged at :173 or :176Users run or deploy this output (for example dist/).

Actors

ActorTrustWhat it can doControl in v2
Repository ownerTrustedChooses the workflow, the token and its permissions:Workflow permissions: (README.md:36-37, 223). Misuse of scopes is out of scope (SECURITY.md).
Contributor with a pull request, same repositoryPartly trustedChanges the code that npm ci and npm run build run, with the workflow’s tokenNone in v2. The token is in the process from the start (:30).
ForkUntrustedChanges the code that runs on pull_requestGitHub withholds secrets and gives a read-only token to workflows run from forks (GitHub platform rule, not a line in this repo).
Compromised dependencyUntrustedRuns install scripts during npm ci (:76)Lock file and npm ci (:75-76). No lock file means npm install (:81) takes the newest versions.
Compromised actionUntrustedRuns in the user’s workflow with the user’s tokenUsers pin @v2, a moving tag (README.md:551). This repo’s own workflows are pinned by SHA (ci.yml:18).

Trust boundaries

The boundary that matters is between the code that runs the project (install and build) and the code that holds the token (commit and push).

PhaseCode that runsToken in the environmentv2 lines
1. Installnpm ci, yarn install, npm install, and their lifecycle scriptsYes (INPUT_GITHUB_TOKEN, read at :30):73-82
2. BuildThe project’s npm run <command>Yes:92-94
3. Commit and pushAction code: git config, gh auth, git add, git commit, git push, gh pr createYes:135-145, :172-177, :215, :224, :231, :258

In v2 there is no boundary. All three phases run in one process inside one container, with the token in the environment. The v3 design (#2, #3) puts phases 1 and 2 on a runner step with no token, and only phase 3 reads it.

Threats

IDThreatHow it worksControl in v2 (status)v3 designAccepted risk (proposed)
T1Install or build code reads the tokenINPUT_GITHUB_TOKEN is in the environment from the start (:30). npm ci (:76) and npm run (:94) are child processes and inherit it. A build script can print or send it.None (open)#2: install and build run without the token. #3: only the commit step reads it.Yes, for v2 until v3 ships.
T2A compromised dependency runs a script during installnpm ci runs the install scripts of every locked package (:76). Its code has the same access as T1.Lock file used (:75-76, :79). Partial: with no lock file, npm install (:81) resolves new versions at build time.#2 removes the token from install. #10 proposes an ignore-scripts input (default off).Yes, for scripts of packages the owner chose.
T3A pull request runs code with the tokenOn pull_request, a branch from the same repository gets the workflow token. Its build runs with T1 access. A fork’s pull request gets no secrets (platform rule).Partial: fork rule from GitHub. Same-repository pull requests: none. A push from a pull_request run fails on a detached HEAD (:258), but the build still runs.#3 refuses a detached HEAD with a clear error. #2 as T1.Yes, for same-repository pull requests, in v2.
T4A bad action version reaches the userUsers write @v2 (README.md:551), a tag that moves. A bad release or a moved tag runs in their workflow with their token.None for users (open). This repo’s workflows are pinned by SHA (ci.yml:18), and workflow-security.yml checks it.#7: the README recommends a SHA pin for a workflow with a write token. #2: setup-node is pinned by SHA.Yes, @v2 is the documented way to use v2.
T5A compromised base image or packageFROM node:20-alpine (Dockerfile:1) and apk add without versions (Dockerfile:4) take whatever the tag points to today.None (open). Planned for v2 in #11: pin the base image by digest and scan with Trivy.#2 removes the image.No.
T6Push loop:215 commits with COMMIT_MESSAGE, whose default has no [skip ci] (:28). A push made with a PAT starts the workflow again (README.md:188, 193). A build that is not reproducible commits again on every run. A push made with GITHUB_TOKEN does not start new runs (GitHub rule), so the loop needs a PAT or app token.None (open). An unchanged build stops the loop by exiting 0 at :194.#3: default message ends with [skip ci], and the PAT loop is documented.No.
T7Detached HEAD: the push fails after the commitOn a tag push or a pull_request run, HEAD is detached. The commit is made (:215), then git push (:258) fails with “You are not currently on a branch” (checked with git locally). The action exits 1 (:289) and leaves the commit in the workspace.None (open). The message says “Failed to push changes” (:286) and does not name the cause.#3: refuses before the commit, with a message that names mode: check or mode: pr.No.
T8A directory is committed that the owner did not chooseIf BUILD_DIR is missing, :101-107 picks the first of dist, build, lib, public, out, .next. :173 force-adds it. :215 commits the whole index, so files that earlier steps staged are committed too.Partial: only BUILD_DIR is staged (:173, :176). The commit still takes the whole index.#2: no guessing, a missing path is an error. #3: only the configured paths are added, with --force on those paths.No.
T9Push goes to a branch the owner did not intend, or rewrites history:258 pushes the current branch, with no refspec. No force push is used. The only --force is on git add (:173).Partial: no force push. The branch is whatever the workflow runs on.#3: pushes the current branch, rebases and retries, never force-pushes. #5: a fixed branch for mode: pr.Yes, by design: the commit goes to the branch the workflow runs on.
T10Parallel runs lose their pushTwo runs on one branch race. A push that is not a fast-forward fails and is not retried (:258).None (open).#3: rebase and retry, and a concurrency group in the docs.No.
T11Build files are owned by the container userThe Dockerfile has no USER line (Dockerfile:1-12). Issue #2 records that the action runs as root, so files it writes can be root-owned in the workspace.None (open).#2: a composite action runs on the runner.No.

Known v2 findings and the v3 fix

FindingWhere in v2Threatv3 fix
The token is visible to npm ci and build scriptsentrypoint.sh:30, :76, :94T1#2 (no token during install and build), #3 (only commit reads it)
A detached HEAD breaks the pushentrypoint.sh:215, :258 (tested locally)T7#3 (refuses with a clear message), #5 (fixed branch)
Push loops with a PAT, or on every unchanged buildentrypoint.sh:28, :215; README.md:188, 193T6#3 ([skip ci] default, PAT loop documented)
A PR with a timestamp branch, and checks that never start with GITHUB_TOKENentrypoint.sh:206, :224, :231; the default token does not start workflowsT3, T6#5 (one PR on a fixed branch, updated in place, PAT or app token documented for checks)
The base image is not pinned by digestDockerfile:1, :4T5#2 (no image). v2 until then: #11 pins by digest and scans with Trivy.
A directory is force-added after a guessentrypoint.sh:101-107, :173T8#2 (no guessing), #3 (--force only on configured paths)
Parallel runs collide on push, and there is no retryentrypoint.sh:258T10#3 (rebase and retry)
No lock file means npm install resolves new versionsentrypoint.sh:81T2Not closed by #2 or #3. No issue requires a lock file yet. Open decision for the maintainer.

Scorecard alerts that are accepted or open

OpenSSF Scorecard runs on main and reports to the code scanning page. Dismissed on 2026-11-12, with the reason written on each alert:

CheckWhy it is accepted
Code-ReviewThis repository has one maintainer, so nobody else can approve a pull request. The risk is a change that nobody else read. It is reduced by the required checks and the protection of main (no force push, linear history, and the tests described in docs/architecture.md). It would be closed by a second maintainer.
FuzzingThe action is shell scripts with no parser and no binary input, and Scorecard does not detect fuzzing for bash. The inputs are tested with hostile values (spaces, quotes, $(), a leading -, newlines and long strings) in tests/validate-inputs.bats and tests/failure-paths.bats.

CII-Best-Practices stays open. It is closed by registering the project at bestpractices.dev and answering its questionnaire.

Threat added after the first audit

IDThreatControl in v3Test
T12A checkout that kept its credentials leaves the token in .git/config, where install and build code can read itvalidate-inputs.sh refuses it in mode commit and mode pr, and warns in mode check and mode build, before the installtests/validate-inputs.bats, the tests that name persisted checkout credentials