
Plumber CLI 0.4 Release - What's New
Plumber CLI 0.4 is here, and it lands nine new controls, makes the Plumber Score the build gate, and adds a live badge you can push straight from CI.
A bigger control library, a severity-weighted A-E score that gates your build, and a badge to show it off.
Nine new controls, and a deeper GitHub surface
Nine new controls landed this cycle, across GitHub and GitLab. They target the supply-chain and secret-exposure classes seen in real incidents over the past year.
đź”— Third-party action supply chain
| Control | Provider | What it checks |
|---|---|---|
| Actions must come from authorized sources | GitHub | Restricts every step-level uses: and job-level reusable-workflow uses: to authorized sources: GitHub-official owners (actions/*, github/*), the scanned repo's own org (trustSameOrgActions, default on), a trustedGithubActions allowlist (exact owner/repo or owner/* wildcard), or a minimumStars popularity floor. |
| Pinned Upstream Actions must exist | GitHub | Flags a step pinned to a 40-character commit SHA that the upstream repository confirms does not exist. Owner-agnostic (first- and third-party alike); non-SHA refs, local ./… actions, and docker:// images are out of scope. Requires gh / GH_TOKEN. |
| Actions must not use ambiguous tag/branch refs | GitHubGitLab | Flags uses: owner/repo@ref references that resolve upstream as both a tag and a branch. API-backed: stays silent when the upstream probe cannot run. |
| Release workflows must not restore an untrusted cache | GitHub | Flags a release or publish job that restores a build cache (actions/cache, actions/cache/restore, or a setup-* action's built-in cache) with a key not scoped to the release ref. Release context is a release trigger or a canonical publish action such as pypa/gh-action-pypi-publish or softprops/action-gh-release. |
🔑 Secret exposure and untrusted code
| Control | Provider | What it checks |
|---|---|---|
| pull_request_target must not check out the PR head | GitHub | Flags workflows on the pull_request_target trigger that check out the PR head (github.event.pull_request.head.sha or head_ref): the PR author's code then runs with the base repository's secrets and a write-scoped GITHUB_TOKEN. A job-level same-repository guard silences the finding, since fork code never runs. |
| Workflow must not write untrusted content to $GITHUB_ENV | GitHub | Flags a run: step where attacker-controlled free text (same field list as ISSUE-207) reaches $GITHUB_ENV or $GITHUB_PATH: inlined directly, dereferenced from an env:-bound shell variable, or written through a heredoc body (sinks: >>, >, tee). |
| Workflows must not expose all secrets at once | GitHub | Flags a job that serialises the whole secrets context with toJson(secrets) or toJSON(secrets) into a run: script, an env: binding, or an action with: input. Scoped references like ${{ secrets.NPM_TOKEN }} are the intended pattern and stay silent. |
| Workflow must not execute unverified scripts | GitHubGitLab | Detects workflow steps that download and immediately execute scripts (curl | bash, wget | sh, download-then-execute, base64 pipe-to-shell) without integrity verification. |
The full control list, with per-control configuration and an issue page for every code, lives in the controls reference for GitHub and GitLab.
Sharper detection
Plumber did not only add controls this cycle. Existing ones got more precise, which matters just as much: a control that cries wolf trains people to ignore it.
- Fewer false positives. Dangerous-trigger detection recognizes fork guards and author-association checks, template-injection is scoped to free-text event fields, and the
curl | interpretercheck flags a real remote fetch target rather than any piped local variable. - A smarter advisory filter. The known-CVE check resolves SHA-pinned action refs to a version, and treats a moving major tag such as
v4as the version span it can float across rather than a single point. - A hardened CLI. Configuration and CI-file reads are bounded, and terminal and merge-request output is escaped, so a crafted repository cannot corrupt a run or an operator’s terminal.
Every new control is a Rego rule on the same OPA policy engine Plumber already uses, so it inherits the shared scoring model, JSON output, and issue codes with no new machinery.
The Plumber Score is the gate now
0.4 makes the Plumber Score the gate: severity-weighted points from 0 to 100, an A-E letter, and a malus for any critical issue. It reflects real risk, not a count of checks that passed.
- Gate on the score. Use
--min-points(0-100) or--min-score(A-E). The default still fails on any finding, but the failure reason is now a real risk number. passedmeans the score gate was met. The JSON report and the GitHub Action drop the old percentage; the default artifact name isplumber-report.- A run with nothing to score fails closed. An empty or provider-mismatched
.plumber.yaml, a--skip-controlsfilter that removes every check, or a GitLab project with no pipeline file, fails instead of scoring a perfect 100 off zero checks. Where that is expected, usesoft-failto keep the job green.
This is the one breaking change in 0.4.0.
INFO
The full points model, severity weights, and the critical malus are documented on the Plumber Score page.
Push your score, get a live badge
Scores are more useful when they are visible. 0.4 adds score push: when Plumber runs in CI, it can publish the default branch’s score to the hosted service at score.getplumber.io and hand you a self-updating A-E badge for your README. It is the only way to get an official Plumber Score.
- No secret to manage. Publishing uses CI-native OIDC, so there is no token to store or rotate.
- CI only. A local
plumber analyzenever publishes, and a scan targeting a foreign repo is skipped automatically. - Opt-in and off by default. Enabling it makes your score and repository name public, and only the default branch’s score is shown.
Turning it on is one input in your pipeline plus a badge line in your README. The step-by-step for both GitHub and GitLab lives in the Plumber Score docs. Here is a published score in the wild:
⬆️ How to update
- CLI:
brew update && brew upgrade plumber, or install via mise, release binaries, or Docker. - GitHub Actions: bump the Plumber action; to publish a badge, add
permissions: id-token: writeand setscore-push: true. - GitLab CI: bump to the latest component catalog entry; set
score_push: trueto publish. - Gates: if you pinned a
--threshold, move to--min-points/--min-score; the deprecated flag still works in the meantime. A run where no control evaluates (an empty config or a skip-all filter) fails closed, so usesoft-fail(findings still reported, job stays green) where that is intentional. - Config: run
plumber config generateorplumber config initto pick up the new controls, then tune per provider. - Verify binaries: deploying release binaries to production? Verify their SLSA attestations first. See verifying release binaries.
INFO
Versions
- Plumber CLI:
- GitLab component: latest catalog
- GitHub Action: Plumber on the GitHub Marketplace

