Skip to main content

GitHub

This page covers the GitHub Actions particulars of the Plumber CLI: how to authenticate, the controls that ship for GitHub, the issues they emit, and a complete github.controls: configuration block. For installation and the provider-agnostic command reference, see the main CLI page.

On GitHub Actions, Plumber flags issues like:

  • Third-party actions not pinned by commit SHA, the tj-actions / reviewdog supply chain vector (CVE-2025-30066)
  • Actions hosted in archived repositories or carrying published GitHub Advisory entries
  • Dangerous triggers (pull_request_target, workflow_run) that expose secrets to PR-author code
  • Template injection via ${{ github.event.* }} inlined into shell scripts
  • Reusable workflows called with secrets: inherit instead of named-secret passing
  • Implicit / over-broad permissions: blocks and permissions: write-all grants
  • Debug-trace toggles (ACTIONS_STEP_DEBUG, ACTIONS_RUNNER_DEBUG) that leak secrets into job logs
  • Docker-in-Docker services and weakened security jobs

Quick Start

  1. Install Plumber (Installation below): Homebrew, mise, a prebuilt binary, Docker, or from source.

  2. Authenticate: run gh auth login, or set GH_TOKEN (see Authentication below).

  3. Run the scan from inside your checked-out repo, or against a remote one (see Running a scan):

    Terminal window
    plumber analyze
  4. Read the report: a per-control compliance breakdown and a letter grade, plus an optional JSON report, PBOM, and CycloneDX SBOM (see Example Output).

Installation

Info

Trying a beta? Homebrew, mise, and Docker Hub follow stable releases only. Beta binaries and checksums live on the GitHub Releases page; each beta’s release notes carry the verified download + checksum steps for that specific build.

Authentication

Pick whichever auth flow fits your environment:

Terminal window
# Option 1: GitHub CLI (recommended for local use)
gh auth login
# Option 2: Fine-grained Personal Access Token
# Settings > Developer settings > Personal access tokens > Fine-grained tokens
# Repository access: pick the repo(s) to scan
# Permissions: Contents = Read, Metadata = Read, Administration = Read
export GH_TOKEN=github_pat_xxxx
# Option 3: Classic PAT (broader scope, still works)
# Permissions: `repo` scope (read access to repo + admin metadata)
export GH_TOKEN=ghp_xxxx

Caution

Administration: Read (fine-grained) or repo scope (classic) is needed for the branchMustBeProtected rule to evaluate force-push and code-owner-approval settings. Without it the rule abstains and Plumber reports the abstention explicitly in partialControls rather than claiming a false 100% pass.

Info

branchMustBeProtected reads both classic Branch Protection (Settings > Branches) and Repository / Organization Rulesets (Settings > Rules > Rulesets). Rules from either mechanism are unioned, stricter wins. A code-owner-approval rule defined only in a Ruleset is honored.

When a token-scoped control cannot fully evaluate, Plumber adds a partialControls entry to report.json so CI gates can tell the difference between “100% compliant” and “100% on what we could see”:

"partialControls": [
{
"control": "branchMustBeProtected",
"reason": "Token lacks Administration:Read scope; force-push and code-owner-approval rules (ISSUE-505) not evaluated.",
"affectedBranches": 1,
"remediation": "Re-run with a token carrying Administration:Read (fine-grained PAT) or `repo` scope (classic PAT)."
}
]

When this array is non-empty, at least one control abstained on at least part of its input. Treat the run as suspect rather than trusting the compliance percentage. On a clean run the array is either omitted or empty.

Running a scan

Terminal window
# Local clone (auto-detected from git remote)
plumber analyze
# Upstream-fetch: scan a repo without cloning it
plumber analyze --github-url github.com --project myorg/myrepo

On GitHub Enterprise Server, pass the GHES host via --github-url ghes.example.com. Plumber auto-detects github.com from your git remote.

Command Reference

plumber analyze

The main command for analyzing GitHub Actions workflows.

Terminal window
plumber analyze [flags]

Flags

FlagRequiredDefaultDescription
--gitlab-urlNo*auto-detectGitLab instance URL (e.g., https://gitlab.com). Mutually exclusive with --github-url.
--github-urlNo*auto-detectGitHub host (e.g., github.com or ghes.example.com). Mutually exclusive with --gitlab-url.
--projectNo*auto-detectProject / repo path. GitLab: group/project. GitHub: owner/repo.
--configNo.plumber.yamlPath to configuration file
--thresholdNo100Minimum compliance % to pass (0-100)
--branchNoProject defaultBranch to analyze
--outputNo-Write JSON results to file
--pbomNo-Write PBOM (Pipeline Bill of Materials) to file
--pbom-cyclonedxNo-Write PBOM in CycloneDX SBOM format
--printNotruePrint text output to stdout
--mr-commentNofalsePost/update a compliance comment on the merge request (MR pipelines only; requires api scope)
--badgeNofalseCreate/update a Plumber compliance badge on the project (requires api scope; only runs on default branch)
--controlsNo-Run only listed controls (comma-separated). Cannot be used with --skip-controls
--skip-controlsNo-Skip listed controls (comma-separated). Cannot be used with --controls
--fail-warningsNofalseTreat configuration warnings (unknown keys) as errors (exit 2)
--ci-config-pathNoauto-detectOverride CI configuration file path. Defaults to project CI config path from GitLab settings (usually .gitlab-ci.yml)
--verbose, -vNofalseEnable verbose/debug output

Info

* Auto-detected from git remote (requires origin) if not specified. Supports both SSH and HTTPS remote URLs.
* You can always override with --gitlab-url and --project

Environment Variables

VariableRequiredDescription
GH_TOKEN / GITHUB_TOKENGitHub onlyGitHub API token. Fine-grained PAT needs Contents: Read, Metadata: Read, Administration: Read. Classic PAT needs repo. Alternatively, run gh auth login and Plumber will pick up the gh CLI credential.
PLUMBER_NO_UPDATE_CHECKNoSet to any value (e.g., 1) to disable the automatic version check.

For the configuration commands (plumber config init / generate / migrate / view / validate), plumber explain, and exit codes, see the main CLI page. They behave identically for both providers.

Usage Examples

Save JSON Output

Terminal window
docker run --rm \
-e GH_TOKEN=ghp_xxxx \
-v $(pwd):/output \
getplumber/plumber:latest analyze \
--github-url github.com \
--project myorg/myrepo \
--branch main \
--config /.plumber.yaml \
--threshold 100 \
--output /output/results.json

Silent Mode (JSON Only)

Terminal window
plumber analyze \
--github-url github.com \
--project myorg/myrepo \
--config .plumber.yaml \
--threshold 100 \
--output results.json \
--print false

Example Output

The CLI output is color-coded in your terminal for easy scanning: green for passing controls, red for failures.

Tip

When using --output, results are saved as JSON for programmatic access and CI/CD integration.

Plumber CLI output showing compliance results

Available Controls

Plumber ships 14 default-on GitHub controls (13 default-on plus the opt-in workflowMustIncludeRequiredActions). Each can be enabled / disabled and customized in .plumber.yaml. When a control detects a violation, it creates an Issue (e.g., ISSUE-701) with a direct link to its documentation page.

ControlIssuesDescription
Container images must not use forbidden tagsISSUE-102 ISSUE-103Flags mutable tags (latest, dev, …); can require all images be pinned by digest
Pipeline must not enable debug traceISSUE-203Flags truthy ACTIONS_STEP_DEBUG / ACTIONS_RUNNER_DEBUG in literal env:, ${{ }} expression bindings, and runtime >> $GITHUB_ENV script writes
Workflow must not inject user input in scriptsISSUE-207Flags ${{ github.event.* }}, ${{ github.head_ref }}, ${{ github.actor }} interpolated directly into a run: shell (OWASP CICD-SEC-1)
Reusable workflows must not inherit secretsISSUE-302Flags jobs.<name>.secrets: inherit, which forwards every caller secret to the reusable workflow. Use an explicit secrets map
Security jobs must not be weakenedISSUE-410Flags CodeQL / dependency-review / gitleaks / trufflehog / OSV-Scanner jobs neutralized via continue-on-error: true or manual-only dispatch (OWASP CICD-SEC-4)
Pipeline must not use Docker-in-DockerISSUE-412 ISSUE-413Flags docker:dind services and insecure daemon configuration (TLS disabled, plaintext port 2375)
Required actions or reusable workflows must be presentISSUE-417Opt-in. Asserts every workflow includes the action(s) your org requires (DNF: outer OR, inner AND), resolving against step-level and reusable-workflow uses:
Branch must be protectedISSUE-501 ISSUE-505Verifies default / named branches have force-push disabled and code-owner approval. Reads classic Branch Protection AND Repository / Organization Rulesets
Third-party actions must be pinned by commit SHAISSUE-701Requires uses: owner/repo@<sha> (40-char commit SHA) instead of a mutable tag or branch ref. trustedOwners exempts first-party actions/* and github/*
Actions must not reference archived repositoriesISSUE-702Flags uses: refs whose upstream GitHub repository has been archived (no security patches coming). One cached API call per unique ref
Actions must not carry known CVEsISSUE-703Cross-references every pinned action against the GitHub Advisory Database (actions ecosystem), semver-filtered so refs past the fix stay silent
Workflows must declare permissionsISSUE-801Flags workflows missing an explicit permissions: block (falls back to the repo-wide GITHUB_TOKEN default)
Workflow uses dangerous triggersISSUE-802Flags pull_request_target and workflow_run, which run with base-repo secrets while being influenceable by an unprivileged caller
Workflow must not grant write-all permissionsISSUE-803Flags permissions: write-all at workflow or job scope. Pairs with workflowsMustDeclarePermissions to close the least-privilege loop
Security Job Weakening Detection (GitHub)

A security job can be silently neutralized while the workflow still shows a green check. This control flags that. Maps to OWASP CICD-SEC-4 (Poisoned Pipeline Execution).

Plumber identifies a security job when its name matches one of the globs in securityJobPatterns. On GitHub the name is <workflow-file-basename-without-.yml>/<job-id> (for example codeql-analysis/analyze for .github/workflows/codeql-analysis.yml + jobs.analyze). The <workflow>/ prefix exists so two workflow files defining a job with the same id never collide.

Four useful pattern shapes:

PatternMatchesWhen to use
*<token>*The token anywhere in the nameYou don’t know which workflow file will host the job
<workflow>/*Every job in one workflow fileAll security jobs live in security.yml
*/<jobid>Specific job id in any workflowJob id is canonical (e.g. codeql) but file location varies
<workflow>/<jobid>Exact match, no wildcardYou control both names and want strict matching
github:
controls:
securityJobsMustNotBeWeakened:
enabled: true
securityJobPatterns:
- "*codeql*"
- "*dependency-review*"
- "*trufflehog*"
- "*gitleaks*"
- "*osv-scanner*"
- "*-sast"
- "*-sast-*"
- "*-scan"
- "*scan*"
- "*-security"
- "*-security-*"
- "*-audit"
- "*-audit-*"
# Real-world slash-form examples (commented; uncomment and
# adapt to your repo for a tighter match than the wildcards
# above). Format: <workflow-filename-without-extension>/<job-id>.
# - codeql-analysis/analyze # GitHub's default CodeQL template
# - dependency-review/dependency-review # GitHub's default Dependency Review template
# - security/gitleaks # gitleaks job in security.yml
# - security/trufflehog # trufflehog job in security.yml
# - security/* # every job in security.yml
# - "*/sast" # any job named "sast", any workflow
# - ci/semgrep-sast # exact match, no wildcard
allowFailureMustBeFalse:
enabled: true
rulesMustNotBeRedefined:
enabled: true
whenMustNotBeManual:
enabled: true
Docker-in-Docker Detection (GitHub)

Flags jobs that spin up a docker:dind service. A Docker daemon inside a CI container on a shared runner in privileged mode enables container escape, lateral movement, and access to secrets from other jobs on the same runner.

When detectInsecureDaemon is enabled (default: true), the control also flags jobs where TLS is disabled (DOCKER_TLS_CERTDIR="") or the Docker host uses the plaintext port (tcp://docker:2375).

github:
controls:
pipelineMustNotUseDockerInDocker:
enabled: true
detectInsecureDaemon: true

Issues

The CLI reports Issues using identifiers like ISSUE-XXX. Each links to a documentation page with a description, the impact, and before/after fix examples. You can also inspect a code locally:

Terminal window
plumber explain ISSUE-703
# shorthand:
plumber explain 703
IssueTitle
ISSUE-102Forbidden container image tag
ISSUE-103Container image not pinned by digest (sub-option of ISSUE-102)
ISSUE-203Workflow enables runner debug logging
ISSUE-207Workflow inlines user input into shell scripts
ISSUE-302Reusable workflow called with secrets: inherit
ISSUE-410Security job weakened
ISSUE-412Docker-in-Docker service detected
ISSUE-413Docker-in-Docker with insecure daemon configuration
ISSUE-417Required action or reusable workflow is missing
ISSUE-501Branch protection missing
ISSUE-505Branch protection configuration not compliant
ISSUE-701Third-party action not pinned by commit SHA
ISSUE-702Action hosted in an archived repository
ISSUE-703Action carries a known security advisory (CVE)
ISSUE-801Workflow permissions not declared
ISSUE-802Workflow uses a dangerous trigger
ISSUE-803Workflow grants write-all permissions

Example Configuration

The github.controls: section of a schema v2 .plumber.yaml:

version: "2.0"
github:
controls:
# Third-party action references must be pinned by 40-character commit SHA.
# trustedOwners is the exemption list; first-party `actions/*` and
# `github/*` are exempt by default.
actionsMustBePinnedByCommitSha:
enabled: true
trustedOwners:
- actions
- github
# uses: owner/repo@ref pointing at an archived GitHub repository.
actionsMustNotBeArchived:
enabled: true
# Cross-references every pinned action against the GitHub Advisory
# Database under the `actions` ecosystem.
actionsMustNotCarryKnownCVEs:
enabled: true
# Same forbidden-tag list as GitLab plus a digest-pinning sub-option.
containerImageMustNotUseForbiddenTags:
enabled: true
tags:
- latest
- dev
- development
- staging
- main
- master
containerImagesMustBePinnedByDigest: true
# Truthy ACTIONS_STEP_DEBUG / ACTIONS_RUNNER_DEBUG in any merged env
# block, expression binding, or runtime $GITHUB_ENV write.
pipelineMustNotEnableDebugTrace:
enabled: true
forbiddenVariables:
- ACTIONS_STEP_DEBUG
- ACTIONS_RUNNER_DEBUG
# Docker-in-Docker services + insecure daemon configuration
# (DOCKER_TLS_CERTDIR="" or DOCKER_HOST tcp://...:2375).
pipelineMustNotUseDockerInDocker:
enabled: true
detectInsecureDaemon: true
# `jobs.<name>.secrets: inherit` hands every secret visible to the
# caller (repo, organisation, environment) to the reusable workflow.
# Declare each secret explicitly instead.
reusableWorkflowsMustNotInheritSecrets:
enabled: true
# See the Security Job Weakening Detection section above for the full
# naming-format reference and slash-form pattern examples.
securityJobsMustNotBeWeakened:
enabled: true
securityJobPatterns:
- "*codeql*"
- "*dependency-review*"
- "*trufflehog*"
- "*gitleaks*"
- "*osv-scanner*"
- "*-sast"
- "*-sast-*"
- "*-scan"
- "*scan*"
- "*-security"
- "*-security-*"
- "*-audit"
- "*-audit-*"
allowFailureMustBeFalse:
enabled: true
rulesMustNotBeRedefined:
enabled: true
whenMustNotBeManual:
enabled: true
# `${{ github.event.* }}`, `${{ github.head_ref }}` or `${{ github.actor }}`
# interpolated directly into a `run:` shell. Bind through env: first.
workflowMustNotInjectUserInputInScripts:
enabled: true
# `pull_request_target` and `workflow_run` run with the base repo's
# secrets while being influenceable by an unprivileged caller.
workflowMustNotUseDangerousTriggers:
enabled: true
# Workflows without an explicit `permissions:` block fall back to the
# repo-wide GITHUB_TOKEN default. Declare `permissions: { contents: read }`
# at the workflow level for least privilege.
workflowsMustDeclarePermissions:
enabled: true
# `permissions: write-all` at workflow or job scope.
workflowMustNotGrantPermissionsWriteAll:
enabled: true
# Opt-in. Assert every workflow includes the action(s) your org requires.
workflowMustIncludeRequiredActions:
enabled: false
# requiredGroups:
# - ["actions/attest-build-provenance"]
# - ["your-org/license-scan", "your-org/sbom"]
# Reads both classic Branch Protection and Repository / Organization
# Rulesets, unions them, stricter wins.
branchMustBeProtected:
enabled: true
defaultMustBeProtected: true
namePatterns:
- main
- master
- release/*
- production
- dev
allowForcePush: false
codeOwnerApprovalRequired: true

See the full configuration reference for every option, and the main CLI page for the provider-agnostic commands, installation, and output formats.

Selective Control Execution

You can run or skip specific controls using their YAML key names from .plumber.yaml. This is useful for iterative debugging or targeted CI checks.

Terminal window
# Only check SHA pinning and declared permissions
plumber analyze --controls actionsMustBePinnedByCommitSha,workflowsMustDeclarePermissions
# Run everything except the advisory-database check
plumber analyze --skip-controls actionsMustNotCarryKnownCVEs

Controls not selected are reported as skipped in the output. The --controls and --skip-controls flags are mutually exclusive.

Valid GitHub control names
Control Name
actionsMustBePinnedByCommitSha
actionsMustNotBeArchived
actionsMustNotCarryKnownCVEs
branchMustBeProtected
containerImageMustNotUseForbiddenTags
pipelineMustNotEnableDebugTrace
pipelineMustNotUseDockerInDocker
reusableWorkflowsMustNotInheritSecrets
securityJobsMustNotBeWeakened
workflowMustIncludeRequiredActions
workflowMustNotGrantPermissionsWriteAll
workflowMustNotInjectUserInputInScripts
workflowMustNotUseDangerousTriggers
workflowsMustDeclarePermissions

Troubleshooting

IssueSolution
no GitHub token found (upstream-fetch mode)Run gh auth login, or set GH_TOKEN / GITHUB_TOKEN. Upstream-fetch refuses to start without a token because the anonymous tier is rate-limited
401 UnauthorizedToken is invalid or expired. Fine-grained PAT needs Contents: Read + Metadata: Read; classic PAT needs repo
branchMustBeProtected shows in partialControlsToken lacks Administration: Read (fine-grained) or repo (classic). The rule abstains rather than claim a false pass
403 / rate-limit errorsAnonymous or under-scoped token. Authenticate with a PAT or gh auth login
404 Not FoundVerify --project owner/repo and, for GHES, that --github-url points at the right host
Branch protection rule not detectedPlumber reads classic Branch Protection AND Rulesets; confirm the rule is enabled (not in evaluate mode) on a branch matching your namePatterns
Configuration file not foundEnsure --config points at the real file (use an absolute path in Docker). Create one with plumber config generate or plumber config init

Info

Need help? Open an issue on GitHub or join our Discord.