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.
| Grade | What it means |
|---|---|
| A | Excellent: very low risk, clean pipeline |
| B | Good: a few Low or Medium issues |
| C | Moderate: Medium issues or accumulating Low findings, worth fixing |
| D | Poor: High-severity issues impacting the pipeline |
| E | Critical: 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
- Add the GitHub Action to your workflow with the correct workflow
permissionsandscore-pushenabled in the actionpermissions:contents: readsecurity-events: writeid-token: writejobs:plumber:steps:- uses: getplumber/plumber@03c6550bdb611ef5712e25e499c2260f91f3299a # pinned plumber version: v0.5.16with:score-push: true - Add a badge in your
README.md(replaceOWNERandREPO)[](https://score.getplumber.io/github.com/OWNER/REPO)
How to publish with GitLab
- Add the GitLab CI component to your
.gitlab-ci.ymlwith thescore_pushinput enabledinclude:- component: gitlab.com/getplumber/plumber/plumber@c342d11f758f4386d9d2c42333cd1c4ae2d5448a # pinned plumber version: v0.5.16inputs:score_push: true - 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
- Link:
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:
| Grade | Points |
|---|---|
| A | ≥ 90 |
| B | 71 – 89 |
| C | 51 – 70 |
| D | 31 – 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:
| Severity | Points lost (first occurrence) | Cap per issue code |
|---|---|---|
| Critical | 25 | none |
| High | 15 | 60 |
| Medium | 6 | 20 |
| Low | 3 | 10 |
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 caprawPoints = 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 Bfails 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.
plumber analyze --min-score BThe 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.