Skip to main content

Plumber Score

Plumber Score

What is a Plumber Score?

Plumber Score is a single A-E grade for your CI/CD security. The open-source Plumber CLI produces it on every run, locally or in your CI pipeline. The score is contextual: Plumber assembles its findings into attack paths and grades the worst realistic consequences, not a flat count of findings.

GradeWhat it means
AExcellent: no significant exposure found
BGood: minor exposure
CModerate: exposure worth fixing
DPoor: significant accumulated exposure
ECritical: a Critical attack path remains, or heavy accumulated losses

A grade from A to D says nothing about the number of paths. An E needs a Critical item: a Critical attack path keeps the score at 30 points or below.

Publish your score

A CI run can publish your score to score.getplumber.io, where it becomes a live badge for your repo.

Publishing is opt-in and never sends your source code: once enabled, Plumber sends its analysis JSON output to score.getplumber.io after each run. Every run that has score push enabled publishes its result for its branch. The public badge and the project’s score follow the default branch only: a merge-request or pull-request run is stored under its branch and never changes the badge.

Example of pushed output
{
"projectPath": "getplumber/plumber",
"projectId": 0,
"defaultBranch": "main",
"ciConfigSource": "local",
"ciValid": true,
"ciMissing": false,
"pipelineOriginMetrics": {
"jobTotal": 14,
"originAction": 7,
"originActionTrustedExempt": 35,
"originActionUnpinned": 0,
"originReusableWorkflow": 0,
"originReusableWorkflowSecretsInherit": 0,
"originTotal": 7,
"workflowsTotal": 5
},
"pipelineImageMetrics": {
"total": 0
},
"minPoints": 100,
"passed": true,
"plumberScore": {
"profileId": "scoring-v4",
"counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
},
"rawPoints": 100,
"rawPointsUnclamped": 100,
"finalPoints": 100,
"score": "A",
"criticalMalusApplied": false,
"losses": [],
"codeLosses": [],
"situation": "Repository: public. Default branch: main, protected. Workflows: 14 jobs in 5 workflows."
},
"actionPinningResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"actionRefsExempt": 35,
"actionRefsTotal": 7,
"actionRefsUnpinned": 0
},
"skipped": false,
"version": "0.1.0"
},
"archivedActionsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"actionRefsArchived": 0,
"actionRefsTotal": 7
},
"skipped": false,
"version": "0.1.0"
},
"authorizedActionSourcesResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"actionRefsTotal": 42,
"actionRefsUnauthorized": 0
},
"skipped": false,
"version": "0.1.0"
},
"branchProtectionResult": {
"data": [
{
"allowForcePush": false,
"branchName": "main",
"codeOwnerApprovalRequired": true,
"default": true,
"protected": true,
"protectionDetailsKnown": true
}
],
"enabled": true,
"metrics": {
"branches": 24,
"branchesToProtect": 1,
"nonCompliantBranches": 0,
"projectsCorrectlyProtected": 1,
"totalProtectedBranches": 1,
"unprotectedBranches": 0
},
"version": "0.2.0"
},
"dangerousTriggersResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"workflowsScanned": 5,
"workflowsWithDangerousTrigger": 0
},
"skipped": false,
"version": "0.1.0"
},
"debugTraceResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"forbiddenFound": 0,
"totalVariablesChecked": 20
},
"skipped": false,
"version": "0.1.0"
},
"dockerInDockerResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"dindServicesFound": 0,
"insecureDaemonFound": 0,
"totalJobsChecked": 14
},
"skipped": false,
"version": "0.1.0"
},
"excessivePermissionsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"jobsTotal": 14,
"jobsWithWriteAll": 0
},
"skipped": false,
"version": "0.1.0"
},
"imageForbiddenTagsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"ciInvalid": 0,
"ciMissing": 0,
"notPinnedByDigest": 0,
"pinnedByDigest": 0,
"total": 0,
"usingForbiddenTags": 0
},
"mustBePinnedByDigest": true,
"skipped": false,
"version": "0.4.0"
},
"knownVulnerableActionsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"actionRefsTotal": 7,
"actionRefsVulnerable": 0
},
"skipped": false,
"version": "0.1.0"
},
"permissionsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"workflowsMissingPermissions": 0,
"workflowsTotal": 5
},
"skipped": false,
"version": "0.1.0"
},
"pullRequestTargetHeadCheckoutResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"headCheckoutsUnderPrTarget": 0,
"workflowsScanned": 5
},
"skipped": false,
"version": "0.1.0"
},
"refConfusionResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"actionRefsAmbiguous": 0,
"actionRefsTotal": 7
},
"skipped": false,
"version": "0.1.0"
},
"requiredActionsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"anySatisfiedGroup": false,
"satisfiedGroups": 0,
"totalGroups": 0
},
"requirementGroups": [],
"skipped": true,
"version": "0.1.0"
},
"reusableSecretsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"reusableCalls": 0,
"reusableCallsSecretsInherit": 0
},
"skipped": false,
"version": "0.1.0"
},
"securityJobsWeakenedResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"securityJobsFound": 1,
"weakenedJobs": 0
},
"skipped": false,
"version": "0.1.0"
},
"templateInjectionResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"scriptLinesChecked": 26,
"templateInjectionsFound": 0,
"workflowsScanned": 5
},
"skipped": false,
"version": "0.1.0"
},
"unverifiedScriptsResult": {
"ciMissing": false,
"ciValid": true,
"issues": [],
"metrics": {
"jobsChecked": 14,
"totalScriptLinesChecked": 26,
"unverifiedScriptsFound": 0
},
"skipped": false,
"version": "0.1.0"
},
"headCommitSha": "18a82651361aae6b23a12694d5954fef28569bd9",
"rawConfig": {
"version": "2.0",
"github": {
"controls": {
"actionsMustBePinnedByCommitSha": {
"enabled": true,
"trustedOwners": ["actions", "github"]
},
"actionsMustNotBeArchived": {
"enabled": true
},
"actionsMustNotCarryKnownCVEs": {
"enabled": true
},
"githubActionMustComeFromAuthorizedSources": {
"enabled": true,
"trustGithubOfficialActions": true,
"trustSameOrgActions": true,
"minimumStars": 0,
"trustedGithubActions": ["jdx/mise-action"]
},
"containerImageMustNotUseForbiddenTags": {
"enabled": true,
"tags": ["latest", "dev", "development", "staging", "main", "master"],
"containerImagesMustBePinnedByDigest": true
},
"pipelineMustNotEnableDebugTrace": {
"enabled": true,
"forbiddenVariables": ["ACTIONS_STEP_DEBUG", "ACTIONS_RUNNER_DEBUG"]
},
"pipelineMustNotUseDockerInDocker": {
"enabled": true,
"detectInsecureDaemon": true
},
"reusableWorkflowsMustNotInheritSecrets": {
"enabled": true
},
"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 }
},
"pipelineMustNotExecuteUnverifiedScripts": {
"enabled": true,
"trustedUrls": []
},
"workflowMustNotInjectUserInputInScripts": {
"enabled": true
},
"workflowMustNotUseDangerousTriggers": {
"enabled": true
},
"workflowsMustDeclarePermissions": {
"enabled": true
},
"workflowMustNotGrantPermissionsWriteAll": {
"enabled": true
},
"workflowMustIncludeRequiredActions": {
"enabled": false
}
}
}
}
}

A few things to keep in mind

  • All published scores are public
  • The score always reflects the latest analysis on your default branch
  • Publishing never fails your pipeline: if the push doesn’t go through, your analysis still succeeds

How to publish with GitHub

  1. Add the GitHub Action to your workflow with the correct workflow permissions and score-push enabled in the action
    permissions:
    contents: read
    security-events: write
    id-token: write
    jobs:
    plumber:
    steps:
    - uses: getplumber/plumber@ad42e1a68a4e0f5d815eea6b43f03a6f1f207345 # pinned plumber version: v0.5.24
    with:
    score-push: true
  2. Add a badge in your README.md (replace OWNER and REPO)
    [![Plumber Score](https://score.getplumber.io/github.com/OWNER/REPO.svg)](https://score.getplumber.io/github.com/OWNER/REPO)

How to publish with GitLab

  1. Add the GitLab CI component to your .gitlab-ci.yml with the score_push input enabled
    include:
    - component: gitlab.com/getplumber/plumber/plumber@eab00bad78e84584f55f02f125740176ca1ff592 # pinned plumber version: v0.5.24
    inputs:
    score_push: true
  2. Add a badge in your repo: Settings → General → Badges
    • Link: https://score.getplumber.io/gitlab.com/%{project_path}
    • Badge image URL: https://score.getplumber.io/gitlab.com/%{project_path}.svg

How is the score determined?

Plumber does not add up findings. It assembles them into attack paths and charges each path for what an attacker could reach. Findings outside any path cost less and can never produce an E on their own.

Every run starts at 100 points. The remaining points map to the A-E grade:

GradePoints
A≥ 90
B71 – 89
C51 – 70
D31 – 50
E< 31

Attack paths

A path reads entry, job, reach.

  • Entry: how someone outside your team gets code or input into a job. A dependency that can change without a commit (an action, an image, an include, a script fetched at build time), an expression an attacker controls in a script (a pull request title), a privileged trigger that runs fork code, a cache an untrusted run can write, an unprotected branch.
  • Walk: the jobs that job feeds, through needs, an artifact or a cache.
  • Reach: what those jobs hold (secrets, a write token, an OIDC token) and what they change (a publish, a deploy, a release, a write to the repository).

One entry makes one path, whatever the number of jobs it enters: one thing to fix, charged once. The path takes the tier of its strongest branch.

TierThe path reaches
Criticalsecrets or a write token, and something the job changes. For an entry anyone can use from a pull request, a secret or a write token alone
Highone of the two: secrets or a write token, or something the job changes. Code execution alone is also High
Mediuma High path held down by one of the limits below
Lowthe entry exists, but no attacker code runs

Some facts move a path one tier, or hold it under a ceiling:

  • A dependency that can change never makes a Critical path alone. Not pinned: Medium at most. Not pinned and from an unauthorized source: High at most. Known bad (a published advisory, a commit absent from the action’s repository, a hidden remote fetch): no ceiling.
  • A push to an unprotected branch: High at most, pushing needs write access.
  • A poisoned cache inherits the ceiling of the paths that reach the jobs able to write it.
  • A missing branch protection or environment approval raises a path one tier when it would have stopped the damage. An environment is never an impact by itself.
  • A private repository lowers a path that needs a contributor by one tier. A fact Plumber could not settle lowers the path one tier and marks it unverified.

Plumber also applies what the platform guarantees: a fork pull request on GitHub gets no secrets and a read-only token, a fork merge request on GitLab never sees protected variables.

Individual findings

A finding on no path is an individual finding. It costs its own severity’s price, and all individual findings together cost at most 30 points, worst first.

A finding on a path costs nothing more: the path pays for it. Each finding shows the severity it is charged at, so a finding on a path shows the path’s tier. Two findings read their severity from context: a missing branch protection (ISSUE-501) is Critical on the default branch and High elsewhere, an action from an unauthorized owner (ISSUE-713) is Medium when pinned by a full commit SHA and High otherwise. Outputs keep the issue’s own severity as baseSeverity.

The formula

ItemPoints lost
Critical attack path30
High attack path15
Medium attack path6
Low attack path3
Critical individual finding20
High individual finding10
Medium individual finding5
Low individual finding2
lost = Critical + min(69, High + min(49, Medium + min(29, Low)))
points = max(0, 100 − lost), capped at 30 while an attack path is Critical

High, Medium and Low are the summed prices of the non-Critical items of that severity, paths and individual findings together, the individual findings within their 30-point budget.

The nested limit gives the letters their meaning: no Critical item, D or better; no High item, C or better; nothing above Low, B or better. Moving an item down a tier never lowers the score. Dismissed findings cost nothing, and repeated occurrences of one finding count once.

A Critical attack path forces an E

While an attack path is Critical, final points are capped at 30 and the grade reads E. A Critical individual finding costs its 20 points and forces nothing.

Example. A public repository: main is unprotected and its plumber job runs on every push with write tokens. Its ci/build job runs on pull requests with a read token, puts the pull request title in a shell, pipes a script from the network into bash, runs node:latest and an action with a published advisory.

  • One High path: a push to main reaches the write tokens.
  • Three High paths into ci/build: the title in the shell, the fetched script, the action with the advisory. Code execution alone is High.
  • One Medium path: node:latest, only not pinned.

4 × 15 + 6 = 66: 100 − 66 = 34, D. Fixing the title in the shell gives back 15 points: 49, still D.

In the report, each attack path ends with the command that prints its findings in full: plumber explain -a N.

When no score is produced

A run whose data collection was degraded, or that evaluated no control, gets no score: the report says so, and the JSON report, the run cache and the PBOM carry none.

The Plumber Platform stores the score the CLI pushes and never recomputes one.

Gate your CI on the score

The score is what passes or fails a plumber analyze run (exit code 1 on failure).

  • --min-score <A-E> is the main gate: the run fails when the letter is below the one you set. --min-score B fails on C, D, E.
  • --min-points <0-100> is the fine-grained variant for gating on exact points. Without any flag, the default is 100 points: any finding fails.
  • Set both and both must pass.
Terminal window
plumber analyze --min-score B

The same gate is available as min-score / min-points inputs on the GitHub Action and min_score / min_points on the GitLab component. The legacy --threshold flag (percentage of passing controls) is deprecated: it still works with a warning, and its old default is equivalent to the default score gate.