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.

GradeWhat it means
AExcellent: very low risk, clean pipeline
BGood: a few Low or Medium issues
CModerate: Medium issues or accumulating Low findings, worth fixing
DPoor: High-severity issues impacting the pipeline
ECritical: at least one Critical issue, or heavy accumulated losses

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-v3",
"counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
},
"rawPoints": 100,
"finalPoints": 100,
"score": "A",
"criticalMalusApplied": false,
"losses": [],
"codeLosses": []
},
"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@03c6550bdb611ef5712e25e499c2260f91f3299a # pinned plumber version: v0.5.16
    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@c342d11f758f4386d9d2c42333cd1c4ae2d5448a # pinned plumber version: v0.5.16
    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?

Every run starts at a perfect 100 points. Plumber scans your CI/CD configuration, and each open issue subtracts points according to its severity. The remaining points map to the A-E grade:

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

Each severity has a weight, and repeated occurrences of the same issue taper off (capped) so a single noisy problem can’t dominate the whole score:

SeverityPoints lost (first occurrence)Cap per issue code
Critical25none
High1560
Medium620
Low310

For a code seen n times, with weight w and cap C, the loss is:

loss = min(w × (1 + 0.5 × log2(n)), C) # Critical has no cap
rawPoints = max(0, 100 − sum of every code's loss)

Each doubling of n adds only half a weight unit, so repeats still hurt but taper off. The cap is per issue code, not per severity: once one code is capped, a different code at the same severity opens a fresh budget, so a pipeline with many distinct problems keeps losing points.

Any Critical issue forces an E

A single Critical finding is an immediate, high-impact risk. While one exists, final points are capped at 30 (finalPoints = min(rawPoints, 30)), so the grade cannot read better than E, however clean the rest of the pipeline is.

For the full per-issue breakdown, run plumber analyze --score-point: every issue code, its weight, and exactly how many points it costs.

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.