Help us shape the future of Go! Take the 2026 Go Developer Survey

control

package
v0.7.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MPL-2.0 Imports: 32 Imported by: 0

Documentation

Overview

Package control runs an analysis end to end: it drives the provider data collections, projects the collected data and the user's configuration onto the Rego engine input, evaluates the policies, and turns findings into per-control statuses, the Plumber score, and the catalog of controls and issue codes that every other consumer (the terminal, the outputs, the platform) reads from.

Index

Constants

View Source
const (
	// ReasonResolutionUnavailable: no merged CI configuration could be
	// obtained for this run, so nothing that reads the merged pipeline can
	// be evaluated.
	ReasonResolutionUnavailable = "resolution_unavailable"

	// ReasonIncludeAttributionUnavailable: a merged configuration IS
	// available, but not the per-include attribution (which include came
	// from which project, at which ref and blob) that only the git host's
	// config-merge API returns. Controls that reason about includes
	// themselves cannot run on merged YAML alone.
	ReasonIncludeAttributionUnavailable = "include_attribution_unavailable"

	// ReasonSnapshotLaneDegraded: the platform reported this control's
	// snapshot lane as a FAILED collection (degraded_fields), so its absence
	// is not evidence of an honestly-empty setting. Distinct from an absent
	// lane the platform vouches for, which a control may legitimately FAIL
	// against.
	ReasonSnapshotLaneDegraded = "snapshot_lane_degraded"

	// ReasonLaneNotServed: the snapshot in hand does not carry this
	// control's data, and the runner's CI_JOB_TOKEN cannot fetch it either.
	//
	// Distinct from ReasonSnapshotLaneDegraded, which says a collection was
	// attempted and FAILED. This one says the data was never on offer:
	// either the contract carries no such lane at all
	// (controlsWithNoPlatformLane), or this deployment's snapshot predates
	// the lane the contract has since grown (lanesWhoseAbsenceIsNotServed).
	// Nothing is broken in either case, and telling an operator their
	// collection failed would send them looking for a fault that is not
	// there.
	ReasonLaneNotServed = "lane_not_served"

	// ReasonIncludeResolutionFailed: at least one include could not be
	// resolved this run, so both the include list and the per-job
	// attribution built from it are INCOMPLETE rather than merely absent.
	//
	// A dropped include takes its jobs with it: the jobs still appear in the
	// merged pipeline but nothing attributes them to the include that
	// contributed them, so they read as project-authored. That direction
	// fabricates findings, which is why this degrades the same controls a
	// missing attribution lane does rather than being reported as a warning
	// beside them.
	ReasonIncludeResolutionFailed = "include_resolution_failed"

	// ReasonUpstreamProbeFailed: a check against the include's SOURCE
	// project could not be completed, so the control has no evidence either
	// way.
	//
	// These probes are fail-safe: only a confirmed result sets the flag they
	// feed, and a failed probe leaves it at its zero value. That is right for
	// the flag and wrong for the report, because the zero value reads as a
	// clean answer - "this ref is not ambiguous" - that nothing established.
	ReasonUpstreamProbeFailed = "upstream_probe_failed"

	// ReasonPlatformObservationMissing: the platform served this run's
	// configuration, and served an include in it WITHOUT the facts about
	// that include - which jobs it contributed, whether its ref resolves
	// upstream as both a tag and a branch.
	//
	// Distinct from ReasonUpstreamProbeFailed, which names a check this run
	// made and could not complete. Nothing was attempted here: those facts
	// come from the include's SOURCE project, which the platform reads and a
	// pipeline job holding only its own credentials cannot. The two reasons
	// send an operator to different places - a token and a permission for
	// one, what the platform has collected for the other - so collapsing
	// them costs the message its only useful half.
	ReasonPlatformObservationMissing = "platform_observation_missing"

	// ReasonRawConfigUnavailable: the project's own UNMERGED CI file could
	// not be read, though the merged pipeline was obtained anyway.
	//
	// The merged document answers almost every rule, so the run is still
	// worth having. Two controls compare the pre-merge file against it, and
	// both fail silently without it rather than loudly: an unread root
	// yields no hardcoded jobs and no local variables, which reads as
	// nothing to report.
	ReasonRawConfigUnavailable = "raw_config_unavailable"

	// ReasonCollectionFailed: a collection this run makes ITSELF could not
	// be completed, so the control that reads it has no data.
	//
	// Distinct from the snapshot reasons above, which describe a lane the
	// PLATFORM could not fill. This one applies in every mode, because a
	// token without the scope for one endpoint is not a platform-mode
	// condition.
	ReasonCollectionFailed = "collection_failed"

	// ReasonConfigRequired: the control is enabled but none of its
	// substantive configuration fields is set, so it asserts nothing
	// (getplumber/plumber#459, platform decision-queue row 19: honest
	// not_evaluable, never a vacuous pass). Which controls can be in this
	// state is authored truth (configuration.ControlMeta.RequiresConfig);
	// which fields count is the catalog's own reflected schema.
	ReasonConfigRequired = "config_required"

	// ReasonLaneNotCollected is the reason a control could not be evaluated
	// because the data collection it reads was never PERFORMED this run.
	//
	// It is the per-policy counterpart of the collection gates: collection
	// is driven by the union of the resolved policies' configurations
	// (platform decision row 62), and if a control still ends up enabled by
	// a policy while its lane was not collected, the honest answer is that
	// nothing was evaluated. The alternative is the vacuous pass this whole
	// file exists to prevent: zero findings over data nobody fetched.
	ReasonLaneNotCollected = "lane_not_collected"

	// ReasonPolicyEvaluationFailed marks a control whose Rego policy failed
	// to evaluate (an engine error such as eval_conflict_error, #489). The
	// run is degraded with a reason naming the policy; every other control
	// keeps its own status and findings.
	ReasonPolicyEvaluationFailed = "policy_evaluation_failed"
)

Machine-readable reasons a control's data lane supplied nothing. They travel to the platform on the finding, so an operator can tell "we could not check this" apart from "this passed" without reading prose.

View Source
const (
	FeedArtifact         = "artifact"
	FeedArtifactAltered  = "artifact_altered"
	FeedArtifactExecuted = "artifact_executed"
	FeedCache            = "cache"
	FeedOutput           = "output"
	FeedOutputCompared   = "output_compared"
	FeedOutputExecuted   = "output_executed"
)

The kinds of edge by which a job feeds another (JobSituation.FeedsVia): an artifact it uploads and the other downloads, a cache it saves and the other restores, the job outputs the other reads through needs (on GitLab, a needs dependency alone, which also downloads the needed job's artifacts). FeedArtifact and FeedOutput are GitLab's own, unsplit kinds; on GitHub each instead carries one of the two typed kinds below, by what the consumer does with the handoff: FeedArtifactExecuted / FeedOutputExecuted when it runs what it received (a script, a with: value, an env value, an image or a reusable call's input), FeedArtifactAltered when it only moves, releases or publishes a downloaded artifact and runs nothing from it, FeedOutputCompared when a needs output is read only inside a comparison the runner evaluates itself (an if:), crossing no code at all. control/paths.go's reachOver gives the three families different reach: full for Executed and the untyped kinds, a single "altered the artifact" impact and no secret for Altered, nothing at all for Compared.

View Source
const (
	StatusPassed  = "passed"
	StatusFailed  = "failed"
	StatusSkipped = "skipped"
	StatusError   = "error"
)

Per-control evaluation statuses. `passed` and `failed` mean the control genuinely evaluated (real data, real check); `skipped` means it never ran (disabled in .plumber.yaml or excluded via --controls/--skip-controls); `error` means it could not be trusted to have fully evaluated — an empty findings list in that state is "could not tell", not "compliant".

View Source
const (
	// MRCommentIdentifier is an invisible HTML comment used to find the Plumber
	// comment in the merge request notes so it can be updated on subsequent
	// runs. The historical wording is kept on purpose: changing it would stop
	// matching comments posted by older versions and create duplicates.
	MRCommentIdentifier = "<!-- Plumber Compliance Comment -->"
)
View Source
const PinnedViaImmutableRelease = "immutable_release"

PinnedViaImmutableRelease is the reason an ISSUE-701 ref that is not a commit SHA still counts as pinned: its tag is backed by an immutable GitHub release.

View Source
const PinnedViaWorkflowLockfile = "workflow_lockfile"

PinnedViaWorkflowLockfile is the reason a step ref that is not a commit SHA still counts as pinned: the scanned branch commits a workflow lockfile (.github/workflows/actions.lock) and the runner executes the commit it records (#542). Never set on a reusable-workflow call.

View Source
const PlumberScoreDocURL = "https://github.com/getplumber/plumber/blob/main/docs/scoring.md"

PlumberScoreDocURL is the canonical user-facing explanation of the Plumber letter score.

View Source
const PlumberScoreProfileID = "scoring-v3"

PlumberScoreProfileID identifies the scoring rules version (see docs/scoring.md).

View Source
const PlumberScoreProfileIDV4 = "scoring-v4"

PlumberScoreProfileIDV4 names the contextual formula (spec section 2).

Variables

View Source
var BlockLabels = []string{"Entry", "So", "Note", "Fix", "Also"}

BlockLabels are the labels a path graph opens its labelled lines on, in the column under the badge: a renderer can set them apart.

View Source
var ScoreProfile = "v4"

ScoreProfile is the scoring formula this run uses: "v4" (attack paths, contextual severity, explanations; the default) or "v3" (the previous per-code severities). It is a process-wide setting: the CLI is single-run, so the cmd layer validates the --score-profile flag once in runAnalyze and sets this before any scoring happens, and both the run-level score (cmd/analyze_shared.go's computeScoreResult) and every per-policy score (ReEvaluateForConfig below) read it, so platform mode gets v4 at the same time as the run's own score.

Functions

func AggregateIssueCodeCounts added in v0.2.22

func AggregateIssueCodeCounts(result *AnalysisResult) map[ErrorCode]int

AggregateIssueCodeCounts walks analysis issues and counts occurrences per ErrorCode, one per distinct identity (row 41): duplicate occurrences of the same finding identity count once, matching the platform's own recompute over the same findings. This is the input expected by ComputePlumberScore in scoring-v3 (per-code caps).

func AnnotateFindingsV4 added in v0.6.0

func AnnotateFindingsV4(result *AnalysisResult)

AnnotateFindingsV4 writes the contextual severity, the base (registered) severity, the role line and the path ids onto every finding, and the path sentence onto every finding that anchors a path, so that each v4 output (JSON, SARIF, push, comment) tells the same story (spec section 3). The caller gates this: it must run only when the run was actually priced under scoring-v4 (never on a v3 run, and never on a v4 request that fell back to v3 for lack of a situation), so a v3 finding's Severity and Data are left exactly as the registry and the engine produced them.

func ApplyGitHubFindingCounts added in v0.3.26

func ApplyGitHubFindingCounts(stats *GitHubAnalysisStats, findings []opaengine.Finding)

ApplyGitHubFindingCounts fills per-control finding counters on stats after Rego evaluation (denominators come from AggregateGitHubStats).

func BestFixSummary added in v0.6.0

func BestFixSummary(score *PlumberScoreResult) string

BestFixSummary is the best fix in one line: what to do, the points it recovers, the points and letter it leads to; or, when no fix moves the score, what to do and why the score stays.

func BucketLosses added in v0.6.0

func BucketLosses(score *PlumberScoreResult) (paths, other float64)

BucketLosses is what the two buckets took off 100.

func CapNotes added in v0.6.0

func CapNotes(score *PlumberScoreResult) []string

CapNotes says which caps kept less than the prices add up to, the lines under the subtraction that make it add up by hand: one per nested cap that held, the widest first ("high, medium and low items count for 69 at most"), then one for the individual findings ("individual findings count for 30 at most"); none when no cap held.

func ControlKeyFor added in v0.4.59

func ControlKeyFor(code string) string

ControlKeyFor names a finding's control the same way platformFindingControlName (cmd/platform_push.go) names it when the finding is PUSHED: the registry lookup's ControlName when the code is known, the raw code string otherwise. The two sides must agree on this fallback, or a finding whose code the CLI's own registry cannot classify would be pushed under its raw code but never matched back against a dismissed entry the platform served under that same raw code.

Exported so cmd/platform_push.go's platformFindingControlName calls this SAME function rather than re-deriving the fallback independently: two copies of "look up the code, else use it raw" can only drift, and a drift here means the push and the matcher disagree about a control's name for exactly the findings that most need them to agree (the unregistered ones).

func ControlPassesFilter added in v0.3.0

func ControlPassesFilter(name string, includeOnly, skip []string) bool

ControlPassesFilter applies the --controls / --skip-controls semantics for one control name. When includeOnly is non-empty, only listed controls pass; skip removes controls from the survivor set. The two flags are mutually exclusive at the CLI level (cmd/analyze_gitlab.go), but this helper handles either or both for callers that don't enforce that.

func CriticalIssueCodesSorted added in v0.1.83

func CriticalIssueCodesSorted(result *AnalysisResult) []string

CriticalIssueCodesSorted returns unique Critical-level issue codes present in the analysis, sorted.

func DisabledControlNames added in v0.3.0

func DisabledControlNames(c *configuration.ControlsConfig) map[string]bool

DisabledControlNames returns the set of control names treated as skipped for the supplied ControlsConfig — both explicit disables (cfg present with IsEnabled() == false) and absent entries (cfg == nil). Matching v0.2.x exactly: at tag v0.2.22, every legacy control wrapper in `control/controlGitlab*.go` sets `p.Enabled = false` when its section is missing from .plumber.yaml, so the Rego port drops those findings via FilterFindingsByEnabledControls. Issue #158 was specifically about v0.3.0-beta.2 keeping absent-control Rego findings in the score; this function feeds the filter to close that path. For securityJobsMustNotBeWeakened the "skipped" call also covers the case where every sub-check is explicitly off — see isSecurityJobsWeakenedSkipped. Pass the right provider's ControlsConfig (use pc.ControlsFor("gitlab") or pc.ControlsFor("github")). DisabledControlNames returns the set of control names that are disabled (nil or enabled:false) in the given ControlsConfig, keyed by the yaml name of each ControlsConfig field.

DERIVED, not hand-written: every field of ControlsConfig is walked and asked IsEnabled, so a control added to the struct is covered the moment it exists. The previous if-chain had to be extended by hand for every new control and never was for actionRefsMustExistUpstream, so a config that disabled it still let its findings through FilterFindingsByEnabledControls into SARIF, GLSAST, PBOM and the score. TestDisabledControlNames_MatchesCatalogSkipFlags pins this map against the catalog's own Skipped flags so the two cannot drift again.

One control keeps a rule of its own: securityJobsMustNotBeWeakened is also disabled when its parent is on but every sub-check is off (see isSecurityJobsWeakenedSkipped).

func ElsewhereNote added in v0.6.0

func ElsewhereNote(n int, on []int) string

ElsewhereNote is the line a control split between attack paths and the other findings carries under the other findings: n more of its findings are listed under the paths numbered on. "" when n is 0.

func EvaluatedControlCount added in v0.4.61

func EvaluatedControlCount(entries []ControlEntry, result *AnalysisResult) int

EvaluatedControlCount counts the entries whose StatusFor is passed or failed: a run or a policy that evaluated nothing real has no basis for a score, and skipped, lane-missing and not_evaluable controls (including the config_required case, #459) are exactly the statuses that mean "nothing real happened here" (platform decision row 45). Findings not attributed to any entry's control name are read as zero for that entry, matching every other StatusFor caller in this package.

func FilterFindingsByEnabledControls added in v0.3.0

func FilterFindingsByEnabledControls(findings []opaengine.Finding, provider string, c *configuration.ControlsConfig, includeOnly, skip []string) []opaengine.Finding

FilterFindingsByEnabledControls drops findings whose ControlName is either (a) currently benched for the given provider — see IsBenched in registry.go — or (b) treated as skipped for the supplied ControlsConfig (explicitly disabled OR absent from the user's config; see DisabledControlNames). Findings whose code is unknown or has no ControlName are kept (defensive: better surfaced than silently swallowed). When the supplied ControlsConfig is nil no skipped-controls claim is made and all findings pass through; callers that want strict skipping must pass a non-nil config. Pass the provider name ("gitlab" or "github") and the matching ControlsConfig (use pc.ControlsFor(provider)).

func FindingFixFor added in v0.6.0

func FindingFixFor(code ErrorCode) (string, bool)

FindingFixFor is the fix of a finding of code read on its own, from the table the best fix reads (findingFixes); false when the table does not name the code.

func FindingLine added in v0.6.0

func FindingLine(f opaengine.Finding, paths []AttackPath) string

FindingLine says what role the finding plays in the score (spec section 3): the path it anchors or amplifies when it is on one, the strongest path a privilege-role finding is walked on (spec section 2, Findings on no path: on a walked job, not on no path), else its registered role read out loud.

func FindingsByControl added in v0.3.0

func FindingsByControl(findings []opaengine.Finding) map[string][]opaengine.Finding

FindingsByControl groups Rego findings by their declared ControlName (from the issue-code registry). Findings whose code has no registry entry land under the "" key so the caller can still surface them if they want. The map values preserve the input order so downstream tables read deterministically.

func GitHubControlCompliance added in v0.3.65

func GitHubControlCompliance(_ string, _ *GitHubAnalysisStats, findings int) float64

GitHubControlCompliance returns the binary compliance percentage for a single GitHub control: 100 when no findings, 0 otherwise. The stats parameter is reserved for future per-control percentage overrides.

func IsRegoFileBenchedForProvider added in v0.3.0

func IsRegoFileBenchedForProvider(content []byte, provider string) bool

IsRegoFileBenchedForProvider returns true when every ISSUE-XXX referenced in content maps to a control name that is currently benched for the given provider (see configuration.IsBenched). When true, the engine should skip loading the file entirely — the policy never executes, no cycles wasted.

Returns false (i.e. "load this file") in any of:

  • the file references no ISSUE codes (helper modules, placeholders);
  • the file references at least one code mapping to a control that is NOT benched for this provider;
  • any ISSUE code is unknown to errorCodeRegistry (defensive: an unknown code is treated as not-bench so the rule still runs and surfaces — better noisy than silently dropped).

The decision is made entirely from existing data: errorCodeRegistry (issue code → control name) and configuration.benchedControls ({provider, control} → benched). No separate file→package mapping lives anywhere; the rego file's own issue-code references are the link.

func JobPhraseOf added in v0.6.0

func JobPhraseOf(job, file string) string

JobPhraseOf is jobPhrase for a job read off one finding, its file the finding's own: "job `triage` from workflow `labeler.yml`" on GitHub, "job `build`" on GitLab.

func ManageMergeRequestComment added in v0.1.42

func ManageMergeRequestComment(
	projectID int,
	mrIID int,
	result *AnalysisResult,
	pc *configuration.PlumberConfig,
	passed bool,
	gateLine string,
	conf *configuration.Configuration,
	score *PlumberScoreResult,
	scoreMode bool,
	scorePointMode bool,
	platform *PlatformPostSummary,
) error

ManageMergeRequestComment creates or updates the Plumber comment on the given merge request. projectID and gitlabURL come from the already-resolved configuration/result; only mrIID is CI-specific. passed is the run's gate verdict and gateLine its human-readable rendering.

func ManageProjectBadge added in v0.1.42

func ManageProjectBadge(
	projectID int,
	conf *configuration.Configuration,
	ps *PlumberScoreResult,
) error

ManageProjectBadge creates or updates the Plumber badge on the project. The badge shows the Plumber letter score (A–E, see ScoreBadgeURL) and links to the score documentation. No-op when no score is available.

func ManageProjectBadgePlatform added in v0.4.60

func ManageProjectBadgePlatform(
	projectID int,
	conf *configuration.Configuration,
	s *PlatformPostSummary,
) error

ManageProjectBadgePlatform is the platform-mode badge (spec s5): the letter is the platform's own global score. When the push returned none, or one that is not a Plumber Score letter (see hasPublishableGlobal), the badge is NOT updated - it keeps its last good value rather than being overwritten with a blank, with something that is not a grade, or with a locally computed letter the platform never agreed to.

func MarkAllSkipped added in v0.4.48

func MarkAllSkipped(entries []ControlEntry, reason string)

MarkAllSkipped marks every entry skipped with the given reason. It backs --no-controls: the catalog still describes what Plumber COULD have checked, but nothing was evaluated, so no entry may report a verdict. Without this, a zero-findings run stamps `status: "passed"` on every control that never ran, which is the false green the flag exists to avoid.

func MarkDismissed added in v0.4.59

func MarkDismissed(findings []opaengine.Finding, served []platform.DismissedIssue) int

MarkDismissed sets Dismissed on every finding whose platform identity matches a served dismissed issue (#447) and returns how many it marked. Entries served under another recipe version are skipped (honest non-suppression, never a cross-version match); control_type is used only to avoid hashing a finding against entries of other controls, keyed by controlKeyFor so it agrees with the raw-code fallback the push side uses. A codeless finding has no identity (identity.PlatformHash reports !ok) and never matches. The marker is a claim about what /context served; the platform's stored issue status stays the authority.

Served entries (after the version filter) that matched no finding at all are counted and logged at Debug, so a systematic mismatch between what the platform served and what this run produced is at least diagnosable, not silently absorbed.

func MarkOwnCollectionGaps added in v0.4.50

func MarkOwnCollectionGaps(result *AnalysisResult, entries []ControlEntry)

MarkOwnCollectionGaps marks the controls whose OWN check could not be completed, independent of where the run got its configuration.

Every input here comes from a collection this CLI performs itself in BOTH modes: a tag-vs-branch probe against an include's source project, a catalogue lookup for its latest version, the project's own unmerged CI file. None of it is platform-specific.

It used to live inside the platform-gated marker, which meant a plain `plumber analyze` recorded these failures and then read nobody: a rate limit or a 403 on the source project left externalRefsMustNotCollide and includesMustBeUpToDate reporting a clean pass. The failures degrade ONE control each rather than the whole attribution set, and they happen on runs where everything else went fine, which is exactly why nothing else caught them.

func MarkSkippedByFilter added in v0.3.0

func MarkSkippedByFilter(entries []ControlEntry, includeOnly, skip []string)

MarkSkippedByFilter mutates entries in place, setting Skipped=true for any control filtered out by --controls / --skip-controls. Called from the renderer so the compliance table shows filtered controls as "skipped" instead of pretending they ran with 100 % compliance. Already-skipped entries (disabled in .plumber.yaml) stay skipped.

func MarkUncollectedLanes added in v0.4.63

func MarkUncollectedLanes(result *AnalysisResult, entries []ControlEntry, provider string)

MarkUncollectedLanes flags every control these entries enable whose gated collection did not run this run.

It exists for the per-policy path. Collection is driven by the union of the resolved policies' configurations, so in the normal case every enabled control's lane WAS collected and this marks nothing. It is the guard for the case the union cannot cover - a lane whose gate did not fire, a run that never reached the gates - where the GitLab statuses abstain with no reason at all and the GitHub ones pass vacuously (platform decision row 62).

Runs AFTER the lane-gap and failed-collection markers: those name a more specific cause (the platform reported the lane degraded, the fetch failed), and MarkNotEvaluable keeps the first reason.

func MarkUnconfiguredControls added in v0.4.59

func MarkUnconfiguredControls(result *AnalysisResult, entries []ControlEntry, pc *configuration.PlumberConfig, provider string)

MarkUnconfiguredControls flags every non-skipped RequiresConfig control whose enabled block sets no substantive field (#459). Runs AFTER the lane-gap marks: a lane gap is the more specific explanation and MarkNotEvaluable keeps the first reason.

func NestedFixMessages added in v0.7.2

func NestedFixMessages(findings []opaengine.Finding)

NestedFixMessages rewrites, in place, the remediation half of every nested finding's message (spec section 6, maintainer review of PR 549): for a code of remediationHalfCodes reached through a remote outermost container, the half after the consequence dash becomes the nested fix, the same text the nested Fix line and the fix detail line read (nestedFix), since the direct half asks to edit a reference the user cannot edit. Every output reads the rewritten message (the terminal So line, JSON, SARIF, GitLab SAST). A direct finding, one under a local outermost composite (ruling L2) and one whose message has no dash are left byte-identical.

func NestedRemediationApplies added in v0.7.2

func NestedRemediationApplies(via []string) bool

NestedRemediationApplies says whether a finding reached through via takes its code's nested remediation (spec 2026-10-16 section 6, Task 3 round ruling L2): only when the outermost reference of the chain is remote. Under a local composite (./...) the user owns the file that holds the reference, so the direct remediation applies, the message still carrying the chain.

func OtherFindingsSummary added in v0.6.0

func OtherFindingsSummary(score *PlumberScoreResult) string

OtherFindingsSummary reads the other-findings bucket out: the counts by severity, worst first; "none" when empty.

func PathGraph added in v0.6.0

func PathGraph(n int, b PathBlock, width int) []string

PathGraph lays a path block out as lines at most width cells wide (a value that cannot fit wraps under itself, never cut): the plain tier badge and the path number, alone on the title line; the Entry line; one branch per entry job, saying where it runs and what it reaches, the jobs it feeds hanging off it; then what the path means, the notes and the fix. The findings are PathGraphFindings, which a renderer prints after it. Every value is printed as given: the caller cleans the block first (PathBlock.Clean). This is the merge request comment's layout, titled "path"; the terminal's is ReportPathGraph, the same lines titled "Attack path".

func PathGraphFindings added in v0.6.0

func PathGraphFindings(findings []PathBlockFinding, width int) []string

PathGraphFindings lists a block's findings under a "Findings" label, one per line with its code, its title and its location, the locations in one column: beside the titles when the width holds them, else each on the line under its title.

func PathIDsFor added in v0.6.0

func PathIDsFor(f opaengine.Finding, paths []AttackPath) []string

PathIDsFor lists every path id a finding is associated with (spec section 3's "pathIds" slot, read by the terminal and JSON outputs): anchored first, then gated, then privilege-on-path (spec section 2, Findings on no path), in path order, deduplicated.

func PathSentence added in v0.6.0

func PathSentence(p AttackPath) string

PathSentence renders one attack path as a single plain-language sentence built from its own evidence (spec section 3): entry clause, reach clause and consequence joined into one sentence, then one trailing sentence per modifier, the whole thing capped at maxSentenceLen on a word boundary. A path with no job (should not happen once AssemblePaths has run) renders as the empty string rather than panic on Jobs[0].

func PathSummary added in v0.6.0

func PathSummary(p AttackPath) string

PathSummary is a path in one line: what its entry is (the block's Entry line), where it runs, and what it reaches.

func PathTierBadge added in v0.6.0

func PathTierBadge(t PathTier) string

PathTierBadge is a tier as the plain badge a path block opens on, six cells wide.

func ReEvaluateForConfig added in v0.4.50

func ReEvaluateForConfig(
	result *AnalysisResult,
	conf *configuration.Configuration,
	provider string,
	pc *configuration.PlumberConfig,
) (scoped *AnalysisResult, score PlumberScoreResult, ok bool)

ReEvaluateForConfig re-runs the rule engine over the pipeline this run already collected, under a DIFFERENT control configuration, and returns the findings plus the score they produce.

It exists for platform mode's per-policy evaluation. The platform serves each policy its own control parameters, and a policy's verdict has to come from its own parameters: reporting a finding computed under policy A's config while claiming policy B's effective config is a false positive under B, not merely an imprecise label.

Nothing is re-collected. The IR is the one the run already built, so this costs a rule evaluation (two under scoring-v4, which also re-evaluates the situation facts) and no git-host traffic. A run with no retained pipeline (the GitHub path, or a limited analysis that never built one) returns ok=false and the caller keeps the run's own verdict rather than inventing an empty one.

func ReportPathGraph added in v0.6.0

func ReportPathGraph(n int, b PathBlock, width int) []string

ReportPathGraph is PathGraph as the terminal report titles it: "Attack path" and its number.

func RoleOnPath added in v0.6.0

func RoleOnPath(f opaengine.Finding, p AttackPath) string

RoleOnPath is FindingLine relative to ONE path, for a renderer listing a finding under every path it belongs to: "Entry of path <id>" only under the path the finding anchors, "Gate: amplifies path <id>" under a path it amplifies, "Privilege: on path <id>" under a path it rides as a privilege finding. A finding on none of those terms with p reads its registered role, as FindingLine does for a finding on no path at all.

func ScoreAdjustment added in v0.6.0

func ScoreAdjustment(score *PlumberScoreResult) string

ScoreAdjustment says how the force or the bottom of the scale changed the final points, "" when neither did.

func ScoreBadgeURL added in v0.1.83

func ScoreBadgeURL(letter string) string

ScoreBadgeURL builds a Shields.io badge URL showing the Plumber letter score (A–E).

func ScoreLetterFromPoints added in v0.4.58

func ScoreLetterFromPoints(finalPoints float64) string

ScoreLetterFromPoints is the scoring-v3 letter band (A>=90, B>=71, C>=51, D>=31, else E) over FINAL points. Exported for the platform, which derives an aggregate's letter through this one table.

func ScoreLetterMeaning added in v0.2.5

func ScoreLetterMeaning(letter string) string

ScoreLetterMeaning returns a short human-readable description of what a letter score implies about the pipeline. It is used by CLI banners, merge request comments, and documentation so wording stays consistent.

func ScoreLetterMeaningFor added in v0.6.0

func ScoreLetterMeaningFor(profileID, letter string, criticalPaths int) string

ScoreLetterMeaningFor returns the letter's description under the formula that produced it (profileID). No scoring-v4 legend asserts a number or kind of paths: a report can land on any letter through other findings alone, with no path at all, so A to D speak only of exposure. E is the one exception, because it is true of every E: criticalPaths (the run's PlumberScoreResult.CriticalPaths) says whether at least one assembled path is Tier Critical, in which case the legend names it; otherwise (two Critical gates and no path, say) it falls back to the accumulated- losses wording. Any other profile keeps ScoreLetterMeaning, the previous per-issue wording, and ignores criticalPaths.

func ScoreLetterRank added in v0.4.0

func ScoreLetterRank(letter string) int

ScoreLetterRank orders letter scores for gate comparisons: A=5 … E=1, 0 for anything unknown, so "at least a B" is Rank(got) >= Rank("B").

func ShortenCommitSHA added in v0.6.0

func ShortenCommitSHA(s string) string

ShortenCommitSHA shows a full commit SHA after an "@" as its first 12 characters, enough to tell commits apart on a screen; the finding's own line and the JSON keep the full reference.

func SituationText added in v0.6.0

func SituationText(facts []SituationFact) string

SituationText is the facts as one line of "Label: value." sentences, the form the JSON report and the platform push carry.

func StatusFor added in v0.4.29

func StatusFor(e ControlEntry, result *AnalysisResult, findingCount int) string

StatusFor derives a control's evaluation status for a run.

Order matters: findings trump degradation — when a control found real violations, `failed` is accurate and actionable regardless of whether the run was complete. The `error` state only guards the opposite case: an EMPTY findings list that cannot be trusted, because the control had nothing real to evaluate (missing/invalid CI config) or its data collection failed mid-run. Presenting that as `passed` is the silent false-green this field exists to eliminate (#220, and the "how do I tell passed from not-evaluated" integration feedback on #353).

branchMustBeProtected is the one repo-level control: it evaluates against the provider's protection API, not the CI configuration, so CiMissing/CiValid do not apply to it — only its own collection signals do (a failed protection fetch, or protection details a token scope could not read). Every other control reads the CI configuration and inherits the CI-level signals.

Run-wide Warnings ("could not verify" messages, e.g. a skipped known-CVE lookup) deliberately do NOT flip a control to error in this version: they are surfaced separately in every output and gated by --fail-warnings. Folding them in per-control would require parsing which control each warning string belongs to.

func TierRank added in v0.6.0

func TierRank(t PathTier) int

TierRank orders tiers: low 1 .. critical 4. An unknown tier ranks 0, below every declared tier, so a caller comparing against a real tier never mistakes an unrecognized value for a match.

func WorstCase added in v0.6.0

func WorstCase(paths []AttackPath) string

WorstCase is what attack path 1, the worst (PathsWorstFirst), means: "<who gets in>, an attacker can <capability> (attack path 1: <subject>)", the block's own So words and the subject of its Entry line; "" with no path.

Types

type AnalysisResult

type AnalysisResult struct {
	// Project information
	ProjectPath   string `json:"projectPath"`
	ProjectID     int    `json:"projectId"`
	DefaultBranch string `json:"defaultBranch"`
	// AnalyzeBranch is the branch the analysis actually ran against
	// (--branch or the project's default). May differ from DefaultBranch.
	AnalyzeBranch string `json:"analyzeBranch,omitempty"`
	// HeadCommitSha is the head SHA of the analyzed branch, when known.
	// Used to build stable remote source links so that, even if the
	// branch moves later, links in the artifact still point at the
	// exact code that produced the finding. Empty when the SHA could
	// not be resolved (e.g. local-only runs without a fetched HEAD).
	HeadCommitSha string `json:"headCommitSha,omitempty"`

	// ArtifactCommitSHA and ArtifactRef are the resolved analyzed commit and
	// its branch/tag, computed once at output time and read by every artifact
	// writer so the JSON report, PBOM, SARIF and OCSF all report the same
	// commit the same way (#443). ArtifactCommitSHA is the resolved SHA, never
	// the literal "HEAD" placeholder (empty when nothing real resolved). They
	// are json:"-" because each writer places them in its own schema slot
	// (the report's headCommitSha, SARIF's versionControlProvenance, ...),
	// never as raw fields on the marshaled result.
	ArtifactCommitSHA string `json:"-"`
	ArtifactRef       string `json:"-"`
	// ArtifactRepoURI is the analyzed project's web URL, carried so SARIF's
	// versionControlProvenance can name the repository the commit belongs to
	// (repositoryUri is required there). Empty when it cannot be derived.
	ArtifactRepoURI string `json:"-"`

	// AnalyzedCIConfig is the CI configuration this run actually evaluated:
	// for GitLab the resolved merged pipeline, for GitHub each scanned
	// workflow file (#443). It carries a json tag so it appears in the JSON
	// report, and only there: no other artifact marshals the result whole.
	AnalyzedCIConfig *AnalyzedCIConfig `json:"analyzedCiConfig,omitempty"`

	// CI configuration status
	CiValid        bool     `json:"ciValid"`
	CiMissing      bool     `json:"ciMissing"`
	CiErrors       []string `json:"ciErrors,omitempty"` // Specific CI config errors from GitLab
	CIConfigSource string   `json:"ciConfigSource"`     // "local" or "remote"

	// Pipeline origin data
	PipelineOriginMetrics *PipelineOriginMetricsSummary `json:"pipelineOriginMetrics,omitempty"`

	// Pipeline image data
	PipelineImageMetrics *PipelineImageMetricsSummary `json:"pipelineImageMetrics,omitempty"`

	// Findings from the Rego/OPA rule engine. Single source of truth
	// for compliance results since all legacy Go controls were retired.
	Findings []opaengine.Finding `json:"findings,omitempty"`

	// Raw collected data (not included in JSON output, used for PBOM generation
	// and for the per-control aggregated stats block printed under each
	// control header in the terminal output).
	PipelineImageData  *gitlab.GitlabPipelineImageData      `json:"-"`
	PipelineOriginData *gitlab.GitlabPipelineOriginData     `json:"-"`
	ProtectionData     *gitlab.GitlabProtectionAnalysisData `json:"-"`
	// VariablesData records the settings-variable collection for the
	// cicdVariablesMustBe* controls. Set only after the collection ran;
	// nil (never ran) or Known=false (401/403) makes those controls
	// report not-evaluable rather than a false pass (see StatusFor).
	VariablesData *gitlab.GitlabVariablesAnalysisData `json:"-"`

	// SecurityPolicyData is the security-policy linkage collection, retained
	// for the same reason ProtectionData and VariablesData are: the
	// per-policy evaluation recomputes this control's tier caveat against
	// the POLICY's configuration, and the two booleans below cannot answer
	// "is a policy project linked" on their own (row 62).
	SecurityPolicyData *gitlab.SecurityPolicyData `json:"-"`

	// SecurityPolicyEvaluable is true when the security policy project linkage
	// was read authoritatively (a successful GraphQL read). False when it could
	// not be read (auth error, or the field is unavailable on the instance), so
	// StatusFor reports projectMustHaveSecurityPolicySource (ISSUE-601) as
	// not-evaluable rather than a false pass.
	SecurityPolicyEvaluable bool `json:"-"`

	// SecurityPolicyTierCaveat is set when the security-policy control ran, the
	// linkage was read, and NO policy project is linked — the tier-ambiguous
	// case (Ultimate-only feature; a non-Ultimate project cannot link one, an
	// Ultimate project may have left it unset). Renderers surface a conditional
	// caveat next to ISSUE-601.
	SecurityPolicyTierCaveat bool `json:"-"`

	// GitHubStats holds per-control denominators computed from the
	// GitHub IR after a GitHub analysis. Used by the GitHub renderer
	// to produce per-control stats blocks ("Total Images: 19,
	// Pinned By Digest: 1, …") and per-control compliance
	// percentages, matching the GitLab output structure. Nil on the
	// GitLab path.
	GitHubStats *GitHubAnalysisStats `json:"-"`

	// Pipeline is the normalized IR this run evaluated (GitLab path). It is
	// retained so a per-policy evaluation can re-run the rules over the same
	// collected data under different parameters: the platform serves each
	// policy its OWN control config, and a policy's verdict must come from
	// its own config rather than from whichever one happened to run first.
	// Nil on the GitHub path, which has GitHubPipeline below.
	Pipeline *ir.NormalizedPipeline `json:"-"`

	// Situation carries the per-job situation facts (trigger facts,
	// privilege, impact, feeds) derived from the pipeline, for the later contextual
	// score to read. Not user-visible: it never serializes and nothing
	// downstream of the report changes because of it.
	Situation *Situation `json:"-"`

	// Paths is set by ComputeScoreForProfile under scoring-v4: the attack
	// paths assembled from Findings and Situation, kept on the result for
	// the outputs that read them. Unset under scoring-v3. Not user-visible
	// on this type: it never serializes, the same way Situation above does
	// not.
	Paths []AttackPath `json:"-"`

	// GitHubPipeline is the normalized IR produced by the GitHub
	// collector, retained on the result so legacy JSON / PBOM /
	// CycloneDX builders can read images, action references, and
	// per-branch protection details without re-running the gitlab.
	// Nil on the GitLab path.
	GitHubPipeline *ir.NormalizedPipeline `json:"-"`

	// Warnings holds non-fatal "could not verify" messages from the run,
	// e.g. a known-CVE check skipped because an action's pinned commit
	// could not be resolved to a version (tag list blocked by an org IP
	// allow list, rate limit, or network). Surfaced in the terminal,
	// JSON, SARIF and GLSAST output, and gated by --fail-warnings (exit
	// 3) so a degraded check is visible instead of silently passing.
	Warnings []string `json:"warnings,omitempty"`

	// UnexpandedActions lists the action references the nested resolution
	// could not expand (GitHub only, spec 2026-10-16 section 7): budget,
	// fetch, expression or api_disabled.
	UnexpandedActions []ir.UnexpandedAction `json:"unexpandedActions,omitempty"`

	// ApprovalRulesTierCaveat is set when an MR approval-rule control ran but
	// the GitLab approvals API returned zero rules — the ambiguous case where
	// the project is either on GitLab Free (feature unavailable, API returns an
	// empty list) or on Premium/Ultimate with no rules configured. The API
	// gives no tier signal to tell them apart, so renderers surface a
	// Premium/Ultimate caveat next to ISSUE-502/504 rather than presenting the
	// result as authoritative.
	ApprovalRulesTierCaveat bool `json:"-"`

	// MRApprovalSettingsTierCaveat is set when the MR approval-settings control
	// ran and the project has no approval protection in effect — the GitLab-Free
	// signature (the feature does not exist there and the API 200-defaults every
	// protection off, which the operator cannot change). The settings API gives
	// no other tier signal, so renderers surface a Premium/Ultimate caveat next
	// to ISSUE-503 advising the operator to disable the control if they are on
	// Free. Any single protection being active proves a paid tier and clears it.
	MRApprovalSettingsTierCaveat bool `json:"-"`

	// MRSettingsPremiumCaveatFields lists the Premium/Ultimate MR-setting
	// expectations (mergePipelinesEnabled, mergeTrainsEnabled) that read as OFF
	// while the config expects them ON. These features require a paid tier, and
	// the project payload gives no tier signal, so an OFF read is ambiguous: a
	// Free project that cannot enable them, or a paid project that left them off
	// (a real misconfiguration). Non-empty => renderers surface a CONDITIONAL
	// caveat next to ISSUE-506 (disable the expectation if on Free; enable the
	// feature if on a paid tier) rather than asserting the tier either way.
	MRSettingsPremiumCaveatFields []string `json:"-"`

	// DataCollectionDegraded is set when a collection or enrichment step
	// failed mid-run, so the analysis ran on incomplete data: a GitLab
	// merged-CI fetch that timed out (empty pipeline), or a GitHub run
	// where some workflow files or the branch-protection fetch could not
	// be retrieved. When true the renderer withholds the letter-score
	// banner and marks the un-collected controls "not evaluated" instead
	// of presenting missing data as 100% compliant (#220). Distinct from
	// CiMissing, which is the legitimate "this project has no CI config"
	// state and is not degraded.
	DataCollectionDegraded bool `json:"dataCollectionDegraded,omitempty"`
	// NestedActionsBudgetExceeded is set when the nested actions resolution
	// stopped at its container budget (monorepo spec 2026-10-16 R3): the
	// closure is incomplete, which a platform push names among its missing
	// fields (actions_closure). Never serialised, the degraded reason says it.
	NestedActionsBudgetExceeded bool `json:"-"`

	// DegradedReasons lists the human-readable collection/enrichment
	// failures behind DataCollectionDegraded (e.g. "3 workflow file(s)
	// could not be fetched", "branch protection could not be fetched").
	// Surfaced as a caveat in the terminal. Empty when not degraded.
	DegradedReasons []string `json:"degradedReasons,omitempty"`

	// NotEvaluable maps a control name to the machine-readable reason its
	// verdict could not be established this run, for controls whose data
	// lane supplied nothing while the rest of the run evaluated normally.
	//
	// It exists because DegradedReasons above is all-or-nothing: any entry
	// turns EVERY control into an error. A lane split — where one source is
	// unavailable and the others are fine — has to say so per control, or
	// the unavailable lane either hides (a silent pass over data nobody
	// collected) or wrongly discredits every other control's verdict.
	//
	// A control listed here reports not_evaluable instead of passing. Real
	// findings still win: a control that DID fire is failed, because a
	// violation found on partial data is still a violation.
	NotEvaluable map[string]string `json:"notEvaluable,omitempty"`

	// CollectedLanes records which GATED data collections this run actually
	// performed, keyed by the lane names in collection_scope.go. A gate that
	// did not fire leaves its lane absent, and the per-policy path then
	// marks the controls reading that lane not_evaluable with
	// lane_not_collected rather than letting an empty result read as a pass
	// (platform decision row 62). True as soon as the collection ran, from
	// the platform's snapshot or from the git host: what matters here is
	// whether the data was sought at all, not whether it arrived.
	CollectedLanes map[string]bool `json:"-"`
}

AnalysisResult holds the complete result of a pipeline analysis

func RunAnalysis

func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error)

RunAnalysis executes the complete pipeline analysis for a GitLab project

func RunGitHubAnalysis added in v0.3.0

func RunGitHubAnalysis(conf *configuration.Configuration) (*AnalysisResult, error)

RunGitHubAnalysis is the GitHub counterpart of RunAnalysis. It scans .github/workflows/*.{yml,yaml} under conf.GitRepoRoot, evaluates the embedded Rego policies against the resulting IR, and returns an AnalysisResult whose only populated fields are the project metadata and Findings. No legacy Go control fields are set — GitHub support is Rego-only by design.

func RunGitHubAnalysisRemote added in v0.3.0

func RunGitHubAnalysisRemote(conf *configuration.Configuration, owner, repo, ref string) (*AnalysisResult, error)

RunGitHubAnalysisRemote is the upstream-fetch counterpart of RunGitHubAnalysis. Instead of walking conf.GitRepoRoot, it fetches `.github/workflows/*.{yml,yaml}` from the provided owner/repo via the GitHub Contents API and runs the same Rego pipeline against the resulting IR. Used by `plumber analyze --github-url X --project owner/repo` when the user does not have a local clone.

Auth is mandatory in remote mode (GH_TOKEN / GH_ENTERPRISE_TOKEN / GITHUB_TOKEN / gh auth login) — without it the Contents API rate-limits aggressively and returns 403 on private repos. Repo- side artefacts that need a local checkout (Dockerfiles, dependabot.yml, SECURITY.md) are not collected; controls that depend on them simply produce no findings.

func (*AnalysisResult) DropNotEvaluableFindings added in v0.4.50

func (r *AnalysisResult) DropNotEvaluableFindings()

DropNotEvaluableFindings removes every finding belonging to a control this run marked not evaluable.

A control is marked when its data lane could not feed it, and a lane can fail in two ways: by supplying nothing, or by supplying something WRONG. The second is why the findings go rather than just the status. Without include attribution, jobs a component contributed are indistinguishable from ones the project wrote, so rules keyed on that distinction fire on upstream jobs behaving normally. Keeping those findings would push fabricated violations to the platform and deduct real score points for them.

Reporting nothing for a control that could not be evaluated is the honest outcome, and the not_evaluable status is what carries the fact that it was checked at all.

func (*AnalysisResult) MarkDegradedSnapshotLanes added in v0.4.50

func (r *AnalysisResult) MarkDegradedSnapshotLanes(entries []ControlEntry, run *platform.RunContext)

MarkDegradedSnapshotLanes flags the controls whose platform snapshot lane failed collection, plus the controls the snapshot has no lane for at all.

This is the distinction the CLI could not previously make. Before the platform served degraded_fields, an absent lane could equally mean "this project genuinely has no branch protections" (a real violation a control SHOULD fail on) or "the collection blew up". Failing on the first and abstaining on the second are both correct; guessing between them is not.

func (*AnalysisResult) MarkFailedCollections added in v0.4.50

func (r *AnalysisResult) MarkFailedCollections(entries []ControlEntry)

MarkFailedCollections flags the controls whose own collection could not be completed, and drops their findings.

It runs in EVERY mode, unlike the snapshot lane marking. The failure it covers is a permission, not a platform: a token that cannot list a project's protected branches gets an error where it expected a list, and an empty protection list is indistinguishable from a project that protects nothing - which is the exact violation branchMustBeProtected exists to report. Left unmarked, the loudest possible false positive fires on every branch the config names.

The branch NAMES survive such a failure, so this is specifically about the protection detail; a control that reads only the branch list would still be evaluable.

func (*AnalysisResult) MarkIncludeAttributionUnavailable added in v0.4.50

func (r *AnalysisResult) MarkIncludeAttributionUnavailable(entries []ControlEntry)

MarkIncludeAttributionUnavailable flags every control that reasons about include attribution as not evaluable. Used when nothing supplied per-include provenance for the merged configuration being evaluated: a snapshot that carried no includes, or a resolve response that served none. Attribution served for a DIFFERENT document does not count, and is never substituted.

func (*AnalysisResult) MarkMergedConfigUnavailable added in v0.4.50

func (r *AnalysisResult) MarkMergedConfigUnavailable(entries []ControlEntry, reason string)

MarkMergedConfigUnavailable flags every control that reads the merged CI pipeline as not evaluable, with reason.

The set is every GitLab control EXCEPT the ones that read a different source entirely: branchMustBeProtected evaluates the provider's branch protection API, so an unavailable CI configuration says nothing about it. Listing the exceptions rather than enumerating the dependents is deliberate — a control added later reads the merged pipeline unless it says otherwise, and defaulting a new control to not_evaluable when the config is missing is the safe direction.

func (*AnalysisResult) MarkNoParsedWorkflow added in v0.7.9

func (r *AnalysisResult) MarkNoParsedWorkflow(entries []ControlEntry)

MarkNoParsedWorkflow flags every GitHub control that reads the workflows as not evaluable when the repository's workflow files are ALL invalid (review of PR 560). CiValid stays true there so ISSUE-423 can report the files, but no workflow was parsed: an empty findings list from a control that reads them must not read as Passed. workflowsMustBeValid itself is left alone, its findings are the point. A no-op as soon as one job was parsed, or when no file was invalid.

func (*AnalysisResult) MarkNotEvaluable added in v0.4.50

func (r *AnalysisResult) MarkNotEvaluable(controlName, reason string)

MarkNotEvaluable records that a control could not be evaluated this run and why. The FIRST reason recorded for a control wins: the earliest lane to come up empty is the most specific explanation, and a later, broader failure should not overwrite it.

The one exception is a policy-failure mark (ReasonPolicyEvaluationFailed, #489), which is recorded before any data lane is examined and is the weakest claim: it says one of the control's modules did not run, not that the data was missing. A data-lane reason recorded later replaces it, so DropNotEvaluableFindings drops the control's findings exactly as it would have without the policy failure.

func (*AnalysisResult) NotEvaluableReason added in v0.4.50

func (r *AnalysisResult) NotEvaluableReason(controlName string) (string, bool)

NotEvaluableReason returns the machine-readable reason this control's own data lane could not feed it, and whether it was marked at all.

Use this, not the run-level DegradedReasons, when explaining a StatusError for ONE control. DegradedReasons describes the RUN, so per-control it either says nothing (when empty) or names an unrelated cause: a run degraded by a failed variables fetch would explain an unevaluable include control with the variables failure.

type AnalyzedCIConfig added in v0.4.52

type AnalyzedCIConfig struct {
	// Path is the GitLab CI config path that was analyzed (the project's
	// ci_config_path, default ".gitlab-ci.yml"). Empty on the GitHub path.
	Path string `json:"path,omitempty"`
	// Content is the GitLab configuration that findings were evaluated
	// against. Merged reports whether it is the include-merged pipeline
	// (true) rather than a single raw file.
	Content string `json:"content,omitempty"`
	Merged  bool   `json:"merged,omitempty"`
	// Workflows are the GitHub workflow files that were scanned, each with
	// its repo-relative path and content. Empty on the GitLab path.
	Workflows []AnalyzedWorkflowFile `json:"workflows,omitempty"`
}

AnalyzedCIConfig is the CI configuration a run evaluated, emitted in the JSON report so a consumer knows exactly which input produced the findings (#443). It is provider-shaped: a GitLab run fills Path/Content/Merged (one resolved pipeline), a GitHub run fills Workflows (each scanned file).

type AnalyzedWorkflowFile added in v0.4.52

type AnalyzedWorkflowFile struct {
	Path    string `json:"path"`
	Content string `json:"content"`
}

AnalyzedWorkflowFile is one scanned GitHub workflow file (#443).

type AttackPath added in v0.6.0

type AttackPath struct {
	ID         string    `json:"id"`
	Tier       PathTier  `json:"tier"`
	BaseTier   PathTier  `json:"baseTier"`
	State      PathState `json:"state"`
	EntryKind  EntryKind `json:"entryKind"`
	Entry      EntryFact `json:"entry"`
	AnchorHash string    `json:"anchorHash"` // the first of AnchorHashes
	AnchorCode ErrorCode `json:"anchorCode"` // the first known-bad code of AnchorCodes, else the first entry-role one
	// AnchorHashes is every anchoring finding (identity.PlatformHash),
	// sorted: findings sharing the path's entry kind and entry subject are
	// one attack, whichever job they enter, so they anchor one path
	// together, and a finding of subjectFindingCodes on that subject and
	// an entry job joins them. AnchorCodes is their codes, sorted and
	// deduplicated.
	AnchorHashes []string    `json:"anchorHashes"`
	AnchorCodes  []ErrorCode `json:"anchorCodes"`
	Jobs         []string    `json:"jobs"` // the entry jobs, strongest branch first, then the jobs they feed
	Reach        Reach       `json:"reach"`
	ReachKind    string      `json:"reachKind"`  // the first of ReachKinds: "impact:<kind>" | "secrets" | "token" | "execution" | "none"
	ReachKinds   []string    `json:"reachKinds"` // every reach kind of Reach, strongest first
	Modifiers    []string    `json:"modifiers"`  // "private_exposure", "unresolvable", "gate:<code>", "push_entry_cap", "dependency_cap", "source_cap", "insider_cap", "maintainer_cap", "pinned_head_cap", "advisory_cap", "cache_writer_cap"
	GateHashes   []string    `json:"gateHashes"` // findings that amplified this path
	Exposure     string      `json:"exposure"`
	// Branches is one per entry job, in Jobs order: the walk from that job,
	// what it reaches, and the tier, state and modifiers it gives on its
	// own. The path takes its tier, modifiers and reach from the first.
	Branches []PathBranch `json:"branches"`

	// Sentence, FindingIDs and Loss are the report fields spec section 4
	// lists, filled by ScoreV4WithExplanations once the path is priced
	// (AssemblePaths leaves them empty): the path's plain-language
	// sentence; the anchor's hash, then the gates', then the consumed
	// privilege findings'; and the price of the path's tier, what the
	// path costs before the nested cap (pathLosses carries what it keeps).
	Sentence   string   `json:"sentence"`
	FindingIDs []string `json:"findingIds"`
	Loss       float64  `json:"loss"`

	// Claims is every claim this path rests on, each with the state the
	// facts behind it settle to (proven, assumed or unknown), computed
	// once at assembly end (claimsFor, control/pathclaims.go), in a fixed
	// order: the entry, the reach's own secrets, each walked edge, the
	// token, the environment, the cache writers, the download guard, and
	// the reach's impacts.
	Claims []Claim `json:"claims"`
	// contains filtered or unexported fields
}

AttackPath is one way a finding's entry fact can be walked to privilege or impact (spec section 2): the anchoring finding, the jobs walked to compute Reach, and the tier/state/modifiers the assembly rules derived from it.

func AssemblePaths added in v0.6.0

func AssemblePaths(findings []opaengine.Finding, sit *Situation, projectPath string) []AttackPath

AssemblePaths joins entry-role (and branch-gate) findings with the situation facts of their job into attack paths (spec section 2). A path is one (entry kind, entry subject): one thing to fix, however many jobs the entry runs in. Every finding anchoring that entry is one attack, so they anchor one path together (AnchorHashes) rather than one path each. Each entry job is a branch of the path, walked and priced on its own (an attacker in one job never gets what another entry job holds), and the path holds the strongest branch's tier, modifiers and reach (strongerBranch); it is priced once. It is a pure function of its inputs: the output order never depends on the input order (TestOutputOrderIsDeterministicUnderShuffledInput).

Two known gaps, by design rather than blocking this assembler. First, Feeds carries no GitLab stage-ordered edges yet (a job without needs/dependencies downloads every earlier stage, but the IR has no Stage field); the walk below under-reports reach on classic stage-ordered GitLab pipelines until that follow-up lands. Second, the walk below (walkedJobs) follows a direct edge only: a job fed by the entry job is reached, a job fed by THAT job is not, because the facts never say whether the intermediate job forwards what it received. A chain more than one hop from the entry is not walked, and is therefore not priced into the path.

projectPath is the analysed repository's "owner/repo" (any case; empty when unknown): a dependency naming that repository itself is the repository's own and starts no path, and an action of its owner is never read out as a third party's.

func PathsWorstFirst added in v0.6.0

func PathsWorstFirst(paths []AttackPath) []AttackPath

PathsWorstFirst orders paths the way every report numbers them (worseFirst: tier descending, then reach strength, then id), so path 1 is always the worst. The caller's slice is never reordered.

func (AttackPath) AllAnchorHashes added in v0.6.0

func (p AttackPath) AllAnchorHashes() []string

AllAnchorHashes is every finding anchoring p: AnchorHashes, or AnchorHash alone for a path built without the list (a hand-built path, or one read back from an older report).

func (AttackPath) EntryJobs added in v0.6.0

func (p AttackPath) EntryJobs() []string

EntryJobs is the entry job of every branch of p, strongest first; the first of Jobs alone for a path built without branches.

func (AttackPath) MarshalJSON added in v0.6.0

func (p AttackPath) MarshalJSON() ([]byte, error)

MarshalJSON writes modifiers, gateHashes, findingIds, anchorHashes and anchorCodes as arrays, empty when the path has none, never null: the JSON report is a public contract, and a consumer iterating a path's modifiers must not have to null-check them.

type BestFix added in v0.6.0

type BestFix struct {
	AnchorHash string    `json:"anchorHash"`
	Code       ErrorCode `json:"code"`
	Job        string    `json:"job,omitempty"`
	Subject    string    `json:"subject,omitempty"`
	// PathID is the attack path the fix removes, empty when the fix is a
	// finding on no path.
	PathID       string  `json:"pathId,omitempty"`
	PointsGained float64 `json:"pointsGained"`
	// NewPoints is the final points once the fix is made.
	NewPoints float64 `json:"newPoints"`
	NewLetter string  `json:"newLetter"`
	Sentence  string  `json:"sentence"`
	// Reason says why PointsGained differs from the price of what the fix
	// removes ("+13 pts: the path's 15, less 2 for a finding that then
	// stands alone"), empty when it does not or when no reason can be read
	// off the two scores. The reports print it; the JSON does not carry it.
	Reason string `json:"-"`
	// Stays is set when no fix moves the final points: the best fix is
	// then the one that recovers the most before the caps, PointsGained is
	// 0, and Stays says why the score does not move yet ("The score stays
	// at 31 until fewer High paths remain."). The reports print it after
	// the fix; the JSON carries it in Sentence.
	Stays string `json:"-"`
	// contains filtered or unexported fields
}

BestFix is the single highest-value fix ComputeBestFix found among a run's attack paths and non-dismissed findings: fixing it (every finding anchoring the path, or every finding sharing the finding's identity) recovers the most FinalPoints of any candidate.

func ComputeBestFix added in v0.6.0

func ComputeBestFix(in ScoreInputV4, sit *Situation, current PlumberScoreResult) *BestFix

ComputeBestFix re-scores the run once per candidate and keeps the trial that recovers the most final points: ties go to the lower code string, then the lower anchor hash (bestFixWins), so the result never depends on the input order. A candidate is one attack path, every finding anchoring it dismissed together (fixing an entry removes every finding on it), or one live identity that anchors no path (an other finding, or a gate that amplifies one). Only a gate can change the paths, so only a gate's trial re-assembles them. A gain that rounds to 0.0 is not a gain. When no candidate gains anything (the caps saturated, or the force holding the score), the best fix is the candidate that recovers the most of the prices before the caps, in the same order, with 0 points gained and Stays saying why; nil only when there is no candidate at all.

Every finding's hash is computed once, up front, and every trial is scored on those hashes (scoreWithHashes) rather than re-hashing the run per candidate.

func (BestFix) MarshalJSON added in v0.6.0

func (b BestFix) MarshalJSON() ([]byte, error)

MarshalJSON writes pointsGained and newPoints rounded to one decimal, the precision the best-fix sentence prints and the push sends; the struct keeps the raw figures, which ComputeBestFix compares candidates on.

type CacheFact added in v0.6.0

type CacheFact struct {
	// Key is what a save and a restore must share to reach each other (a
	// caching action's discriminators included: a dependency path, a
	// buildx scope, a rust-cache job id); Family is the name to print, the
	// key without them.
	Key    string `json:"key"`
	Family string `json:"family"`
	Mode   string `json:"mode"` // "restore", "save" or "both"
	Prefix bool   `json:"prefix"`
	Uses   string `json:"uses"`
	Line   int    `json:"line"`
	// Parts is the discriminators of the real key a caching action builds
	// itself (setup-node, setup-python, setup-go, setup-uv, rust-cache)
	// that this program resolved to a concrete value, by name: "os",
	// "arch", "version", "manager". A name absent here is a part
	// cacheKeyTemplate (control/cachekeys.go) cannot resolve either, so it
	// stays an open placeholder, matching whatever the other side carries,
	// never a literal guess (Parts is additive, nil for every caller that
	// predates it).
	Parts map[string]string `json:"parts,omitempty"`
}

CacheFact is one cache of a job (ir.CacheRef): its key, or its key prefix when Prefix is set (a restore-keys fallback, or a caching action keying on a fixed prefix followed by a run-time hash), whether the step restores, saves or both, and the step declaring it.

type CallerFact added in v0.6.0

type CallerFact struct {
	Job              string   `json:"job"`
	TokenWrite       []string `json:"tokenWrite"`
	TokenWriteSource string   `json:"tokenWriteSource"`
	Secrets          []string `json:"secrets"`
	AllSecrets       bool     `json:"allSecrets"`
	Triggers         []string `json:"triggers"`
}

CallerFact is one call of a job of a reusable workflow of the repository (JobSituation.Callers): the calling job and what the call gives the called job, its token (TokenWriteSource "log"/"effect" when nothing up the chain declares permissions but the repository default was read off a run's log or a bot-authored write, "unknown" when neither route decided it), the secrets it passes by their caller-side names, whether it passes every secret (secrets: inherit), the events it runs on.

type CatalogControl added in v0.4.56

type CatalogControl struct {
	ID             string   `json:"id"`
	Name           string   `json:"name"`
	DisplayName    string   `json:"displayName"`
	Category       string   `json:"category"`
	Providers      []string `json:"providers"`
	Description    string   `json:"description"`
	RequiresConfig bool     `json:"requiresConfig"`
	// RequiresTier is the GitLab plan this control needs (premium or
	// ultimate), absent when every plan can satisfy it. A control whose
	// gate is one field only carries nothing here: the tier rides on the
	// field inside ConfigSchema (ask 56 of the 2026-09-22 issues-page
	// review).
	RequiresTier string                             `json:"requiresTier,omitempty"`
	ConfigSchema *configuration.ControlConfigSchema `json:"configSchema,omitempty"`
	IssueCodes   []string                           `json:"issueCodes"`
	// IssueCodeRoles maps each of IssueCodes to its role (entry, privilege,
	// gate, hygiene), the input the contextual score weighs a finding by. A
	// sibling map so the IssueCodes array keeps its shape for consumers.
	IssueCodeRoles map[string]string `json:"issueCodeRoles"`
	// IssueCodeEntryKinds maps each entry-role code in IssueCodes to the
	// situation fact it anchors to (pr_target, untrusted_expression,
	// mutable_dependency, poisoned_cache). Codes with another role are
	// absent: a sibling map, same shape rule as IssueCodeRoles.
	IssueCodeEntryKinds map[string]string `json:"issueCodeEntryKinds"`
}

CatalogControl is one control in the exported catalog: the CTRL-XXX immutable id, the stable technical name, display metadata, the welded config schema when the control has a config block, and the ISSUE codes it can emit (#458).

type CatalogDocument added in v0.4.56

type CatalogDocument struct {
	CatalogVersion int                `json:"catalogVersion"`
	CLIVersion     string             `json:"cliVersion"`
	Controls       []CatalogControl   `json:"controls"`
	IssueTypes     []CatalogIssueType `json:"issueTypes"`
}

CatalogDocument is the whole exported catalog in one versioned envelope: what the platform imports through Catalog() and the catalog command serializes as JSON (#458). CatalogVersion identifies the document shape; consumers are expected to be forward-tolerant to added fields.

func Catalog added in v0.4.56

func Catalog(cliVersion string) CatalogDocument

Catalog assembles the CLI's whole catalog into one document: the single source the platform imports (Go) and the catalog command serializes (JSON) so no consumer maintains a divergent copy (#458). cliVersion is injected by the caller (cmd passes the build version; tests pass a constant) so the document itself stays deterministic.

type CatalogIssueType added in v0.4.56

type CatalogIssueType struct {
	Code        string `json:"code"`
	Severity    string `json:"severity"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Remediation string `json:"remediation"`
	DocURL      string `json:"docUrl"`
	ControlName string `json:"controlName"`
}

CatalogIssueType is one issue type in the exported catalog: the ISSUE-XXX immutable id with the CLI's own wording (title, description, remediation) and its owning control, so consumers render the engine's words instead of maintaining a copy (#458).

type Claim added in v0.7.1

type Claim struct {
	Kind    string     `json:"kind"`
	Subject string     `json:"subject"`
	State   ClaimState `json:"state"`
	Source  string     `json:"source,omitempty"`
}

Claim is one proven-or-not statement an attack path rests on: what kind of claim it is, the subject it is about, how settled it is, and, when it is not proven outright, the source of the assumption or the gap (a default token, an unresolved fact, and the like).

type ClaimState added in v0.7.1

type ClaimState string

ClaimState is how settled a claim about an attack path is: proven when the facts behind it are all settled, assumed when it rests on a default taken for lack of a declared signal, unknown when a fact it needs could not be decided at all. Only a claim that folds to ClaimProven moves the tier; the other two are the gap between what Plumber can show and what it is only willing to say.

const (
	ClaimProven  ClaimState = "proven"
	ClaimAssumed ClaimState = "assumed"
	ClaimUnknown ClaimState = "unknown"
)

type CodeLoss added in v0.2.22

type CodeLoss struct {
	Code         ErrorCode     `json:"code"`
	Severity     IssueSeverity `json:"severity"`
	Count        int           `json:"count"`
	Weight       float64       `json:"weight"`
	Cap          float64       `json:"cap,omitempty"` // omitted when infinite (critical)
	UncappedLoss float64       `json:"uncappedLoss"`
	CappedLoss   float64       `json:"cappedLoss"`
}

CodeLoss is the points lost for a single issue code after weight, log growth, and per-severity cap. scoring-v3 caps loss per (code), so distinct types at the same severity each contribute their own bucket.

type CodeRole added in v0.5.20

type CodeRole string

CodeRole is how a finding enters the score (spec section 2). Exactly one per code; the catalog golden pins the table.

const (
	RoleEntry     CodeRole = "entry"     // anchors an attack path
	RolePrivilege CodeRole = "privilege" // worsens what a reached job holds; hygiene when on no path
	RoleGate      CodeRole = "gate"      // removes a protection; amplifies a path, never the E cap alone
	RoleHygiene   CodeRole = "hygiene"   // on no path by construction
)

func RoleForCode added in v0.5.20

func RoleForCode(code ErrorCode) CodeRole

RoleForCode returns the code's role; an unknown code is hygiene, so it can never anchor a path or amplify one.

type ControlEntry added in v0.3.0

type ControlEntry struct {
	DisplayName string
	ControlName string
	Skipped     bool
	// SkipReason is the human-friendly explanation rendered next to
	// the "(skipped)" header and in the Status: SKIPPED (…) line. Left
	// empty for the default "disabled in configuration" case; callers
	// that flip Skipped for a different reason (e.g. a required data
	// source was unavailable) set this so the operator sees why.
	SkipReason string
	Compliance float64
}

ControlEntry is the canonical per-control view consumed by the analyze renderer, the MR comment builder and any future output path. Compliance is derived from the Rego Findings list (binary: 100 when no finding matches the ControlName, 0 when at least one does); Skipped reflects whether the user disabled the control in .plumber.yaml. DisplayName is the user-facing title.

func GitHubControls added in v0.3.0

func GitHubControls(pc *configuration.PlumberConfig) []ControlEntry

GitHubControls returns the catalog of GitHub Actions controls in their canonical display order — the same shape GitLabControls uses. Every non-benched GitHub control is returned. Skip semantics mirror GitLabControls and v0.2.x's GitLab legacy code: absent from `github.controls.*` OR `enabled: false` (or empty entry) → Skipped=true. Benched controls remain invisible because their findings never reach the catalog.

func GitLabControls added in v0.3.0

func GitLabControls(pc *configuration.PlumberConfig) []ControlEntry

type EntryFact added in v0.5.19

type EntryFact struct {
	Kind     EntryKind `json:"kind"`
	State    FactState `json:"state"`
	Evidence string    `json:"evidence"`
	Subject  string    `json:"subject"`
	File     string    `json:"file,omitempty"`
	Line     int       `json:"line,omitempty"`
	// CacheScopes is the scope the run that executes this entry saves its
	// cache under (entry_cache_scope): "pull_request" for a fork pull
	// request (its run never leaves the pull request's merge ref), the
	// job's own CacheScopes for every other entry this program builds
	// structurally (fork_pr, unprotected_push): the fact does not narrow
	// what a push or a dependency entry already tells the job's triggers.
	// Empty for an entry read off a finding rather than a situation fact
	// (control/paths.go computes the equivalent scope itself, off the
	// entry's own kind, for those).
	CacheScopes []string `json:"cacheScopes,omitempty"`
	// Actor is who can make the entry's job run (ir.GateClass): "outsider"
	// on a fork pull request entry and on a privileged entry no gate
	// holds, the class of the entry job's gate otherwise
	// (control/actor.go). "unknown" is the true value when the gate could
	// not be decided: the path then takes the outsider's mechanics, in the
	// unverified state, one tier down. Gate is that gate's reason ("label
	// x", "author x" for the pull request's author, "actor x", "environment
	// x requires reviewers", "merged"), and PinnedSha whether what the
	// entry runs is the head commit that was approved (a checkout only).
	// All three empty on an entry no outsider reaches through a trigger (a
	// dependency, a push, a cache).
	Actor     string `json:"actor,omitempty"`
	Gate      string `json:"gate,omitempty"`
	PinnedSha bool   `json:"pinnedSha,omitempty"`
	// Via is the chain a nested entry finding was reached through,
	// outermost first (monorepo spec 2026-10-16 section 6); empty on a
	// direct entry.
	Via []string `json:"via,omitempty"`
}

EntryFact is one way the job can be made to run (or run on input it does not control the trust of), with the evidence that proved it.

type EntryKind added in v0.5.19

type EntryKind string

EntryKind names the way an attacker-controlled input can make a job run, read from the issue-code registry (EntryKindForCode); fork_pr and unprotected_push are also the kinds of the two structural trigger facts.

const (
	EntryForkPR              EntryKind = "fork_pr"
	EntryPRTarget            EntryKind = "pr_target"
	EntryUntrustedExpression EntryKind = "untrusted_expression"
	EntryMutableDependency   EntryKind = "mutable_dependency"
	EntryUnprotectedPush     EntryKind = "unprotected_push"
	// EntryPoisonedCache is a job granted cache write on an untrusted
	// trigger (cache-mode: write on pull_request_target, issue_comment...):
	// code running in it can write caches the jobs restoring one read.
	EntryPoisonedCache EntryKind = "poisoned_cache"
)

func EntryKindForCode added in v0.5.20

func EntryKindForCode(code ErrorCode) (EntryKind, bool)

EntryKindForCode returns the entry kind of an entry-role code.

type ErrorCode added in v0.1.67

type ErrorCode string

ErrorCode represents a unique Plumber issue code (ISSUE-XXX format).

ISSUE codes are immutable identifiers (#458): the platform keys durable state (issues, history, dismissals) on them. A code is never renumbered, never reused for a different meaning, and survives any rename of titles or control names. TestIssueCodesAreUnique pins uniqueness; TestEveryRegoIssueCodeIsRegistered pins that no policy emits an unregistered code.

const (
	// ISSUE-101: Container image comes from an unauthorized registry
	CodeImageUnauthorizedSource ErrorCode = "ISSUE-101"
	// ISSUE-102: Container image uses a forbidden tag (e.g., latest, dev)
	CodeImageForbiddenTag ErrorCode = "ISSUE-102"
	// ISSUE-103: Container image is not pinned by digest
	CodeImageNotPinnedByDigest ErrorCode = "ISSUE-103"
	// ISSUE-701: Third-party GitHub Action reference is not pinned by commit SHA
	CodeActionUnpinned ErrorCode = "ISSUE-701"
	// ISSUE-704: Container registry password is hard-coded in the workflow
	CodeContainerHardcodedCredentials ErrorCode = "ISSUE-704"
	// ISSUE-705: Job granted cache write (cache-mode: write) on an untrusted trigger
	CodeCachePoisoning ErrorCode = "ISSUE-705"
	// ISSUE-717 is RETIRED, do not reuse. It carried the unresolvable
	// conditional-cache variant of the old release cache check, removed
	// with it in issue #533.
	// ISSUE-702: Action is hosted in an archived GitHub repository
	CodeActionArchivedRepo ErrorCode = "ISSUE-702"
	// ISSUE-707: Pinned commit SHA does not exist in the action's upstream repository
	CodeImpostorCommit ErrorCode = "ISSUE-707"
	// ISSUE-708: `# vX.Y.Z` comment does not match the SHA the ref resolves to
	CodeRefVersionMismatch ErrorCode = "ISSUE-708"
	// ISSUE-709: Action pinned by SHA is stale vs the latest upstream release
	CodeStaleActionRef ErrorCode = "ISSUE-709"
	// ISSUE-703: Action version carries a published security advisory
	CodeKnownVulnerableAction ErrorCode = "ISSUE-703"
	// ISSUE-711: Third-party action duplicates a runner built-in (gh CLI, etc.)
	CodeSuperfluousAction ErrorCode = "ISSUE-711"
	// ISSUE-706: Dockerfile FROM reference is not pinned by digest
	CodeDockerfileUnpinnedBase ErrorCode = "ISSUE-706"
	// ISSUE-712: Release / publish workflow produces unsigned artefacts
	CodeReleaseWorkflowUnsigned ErrorCode = "ISSUE-712"
	// ISSUE-713: Third-party GitHub Action comes from an unauthorized source
	CodeActionUnauthorizedSource ErrorCode = "ISSUE-713"
	// ISSUE-714: Action fetches and runs remote code from a mutable ref, in the open (no integrity check)
	CodeActionMutableRemoteExec ErrorCode = "ISSUE-714"
	// ISSUE-715: Action OBFUSCATES a remote code fetch/exec (decode-then-run)
	CodeActionObfuscatedRemoteExec ErrorCode = "ISSUE-715"
	// ISSUE-716: Action source could not be verified (fetch failed): not a pass
	CodeActionRemoteExecUnverified ErrorCode = "ISSUE-716"
)

Issue codes for container image controls (1xx)

const (
	// ISSUE-201: CI/CD settings variable is not protected (exposed to pipelines on unprotected branches)
	CodeCicdVariableUnprotected ErrorCode = "ISSUE-201"
	// ISSUE-202: CI/CD settings variable is not masked (its value prints in job logs)
	CodeCicdVariableUnmasked ErrorCode = "ISSUE-202"
	// ISSUE-203: Pipeline enables CI debug trace (CI_DEBUG_TRACE or CI_DEBUG_SERVICES)
	CodeDebugTraceEnabled ErrorCode = "ISSUE-203"
	// ISSUE-204: Unsafe variable expansion in shell re-interpretation context (eval, sh -c, etc.)
	CodeUnsafeVariableExpansion ErrorCode = "ISSUE-204"
	// ISSUE-205: A variable that should only be set in CI/CD Settings is overridden in the pipeline config
	CodeJobVariableOverridden ErrorCode = "ISSUE-205"
	// ISSUE-207: Workflow inlines user-controlled template expressions into a run: script
	CodeTemplateInjection ErrorCode = "ISSUE-207"
	// ISSUE-208: Workflow re-enables deprecated GitHub Actions workflow commands
	CodeInsecureCommands ErrorCode = "ISSUE-208"
	// ISSUE-209: Workflow writes untrusted content to $GITHUB_ENV or $GITHUB_PATH
	CodeGitHubEnvInjection ErrorCode = "ISSUE-209"
	// ISSUE-210: Workflow gates behaviour on a spoofable actor/bot check
	CodeBotConditions ErrorCode = "ISSUE-210"
	// ISSUE-211: Workflow `if:` condition is logically unsound (always true/false, tautology)
	CodeUnsoundCondition ErrorCode = "ISSUE-211"
	// ISSUE-212: Workflow misuses the `contains()` built-in (argument order, type)
	CodeUnsoundContains ErrorCode = "ISSUE-212"
	// ISSUE-215: Workflow expands a `vars.*` template into a shell script
	CodeTemplateInjectionVars ErrorCode = "ISSUE-215"
	// ISSUE-213: Workflow exports the whole `github` context via toJson(github)
	CodeUnsafeGithubContextDump ErrorCode = "ISSUE-213"
	// ISSUE-214: Workflow installs a package without pinning a version / lockfile
	CodeUnpinnedPackageInstall ErrorCode = "ISSUE-214"
)

Issue codes for CI/CD variable controls (2xx)

const (

	// ISSUE-302: Reusable workflow called with `secrets: inherit`
	CodeSecretsInherit ErrorCode = "ISSUE-302"
	// ISSUE-303: Secret dereferenced via fromJSON bypasses log redaction
	CodeUnredactedSecrets ErrorCode = "ISSUE-303"
	// ISSUE-801: Workflow grants no explicit permissions, relying on the repo default
	CodeUndocumentedPermissions ErrorCode = "ISSUE-801"
	// ISSUE-305: Secret used without an environment gate
	CodeSecretsOutsideEnv ErrorCode = "ISSUE-305"
	// ISSUE-306: GitHub App token issued with revocation disabled
	CodeGitHubAppSkipRevoke ErrorCode = "ISSUE-306"
	// ISSUE-307: Checkout persists credentials in .git/config, latent
	// hygiene (low). The demonstrable-leak escalation is ISSUE-310.
	CodeArtipacked ErrorCode = "ISSUE-307"
	// ISSUE-308: Workflow reads a secret via a dynamic index (secrets[expr])
	CodeSecretsDynamicIndex ErrorCode = "ISSUE-308"
	// ISSUE-309: Workflow exfiltrates the entire secrets context via toJson(secrets).
	// Moved from ISSUE-301 in the 301/309 swap so the leaked-secrets rule could
	// take the slot already used by the downstream jobs platform.
	CodeOverprovisionedSecrets ErrorCode = "ISSUE-309"
	// ISSUE-310: Persisted checkout credentials are packed into an uploaded
	// artifact (`.git` in the artifact): the demonstrable ArtiPACKED leak.
	CodeArtipackedExfiltrated ErrorCode = "ISSUE-310"
)

Issue codes for secret and credential handling controls (3xx)

const (
	// ISSUE-401: Job is hardcoded (not sourced from include/component)
	CodeJobHardcoded ErrorCode = "ISSUE-401"
	// ISSUE-402: External CI ref collides with both a tag and a branch upstream
	// (GitLab include/component @ref, GitHub action/reusable-workflow @ref)
	CodeRefConfusion ErrorCode = "ISSUE-402"
	// ISSUE-403: Include uses an outdated version
	CodeIncludeOutdated ErrorCode = "ISSUE-403"
	// ISSUE-404: Include uses a forbidden version
	CodeIncludeForbiddenVersion ErrorCode = "ISSUE-404"
	// ISSUE-405: Required template is missing from the pipeline
	CodeTemplateMissing ErrorCode = "ISSUE-405"
	// ISSUE-406: Required template jobs are overridden
	CodeTemplateOverridden ErrorCode = "ISSUE-406"
	// ISSUE-408: Required component is missing from the pipeline
	CodeComponentMissing ErrorCode = "ISSUE-408"
	// ISSUE-409: Required component jobs are overridden
	CodeComponentOverridden ErrorCode = "ISSUE-409"
	// ISSUE-410: Security job is weakened (allow_failure, rules override, when: manual)
	CodeSecurityJobWeakened ErrorCode = "ISSUE-410"
	// ISSUE-411: Pipeline executes unverified scripts (curl|bash, base64|bash, download-then-exec, |sh)
	CodeUnverifiedScriptExecution ErrorCode = "ISSUE-411"
	// ISSUE-412: CI/CD job uses a Docker-in-Docker (dind) service
	CodeDockerInDockerUsage ErrorCode = "ISSUE-412"
	// ISSUE-413: CI/CD job uses Docker-in-Docker with insecure daemon configuration
	CodeDockerInDockerInsecure ErrorCode = "ISSUE-413"
	// ISSUE-414: GitLab CI/CD component comes from an unauthorized source
	CodeComponentUnauthorizedSource ErrorCode = "ISSUE-414"
	// ISSUE-415: GitLab CI/CD function comes from an unauthorized source (deprecated
	// reference forms are counted as a stat, not flagged)
	CodeFunctionUnauthorizedSource ErrorCode = "ISSUE-415"
	// ISSUE-802: Job reaches a dangerous trigger (workflow_run, issue_comment,
	// pull_request_review*, discussion*, gollum, fork) AND checks out fork
	// content. pull_request_target is owned by ISSUE-804.
	CodeDangerousTriggers ErrorCode = "ISSUE-802"
	// ISSUE-804: pull_request_target workflow explicitly checks out the PR head (tj-actions pattern)
	CodePullRequestTargetWithHeadCheckout ErrorCode = "ISSUE-804"
	// ISSUE-417: Required action or reusable workflow is not referenced anywhere in the project's workflows
	CodeRequiredActionMissing ErrorCode = "ISSUE-417"
)

Issue codes for pipeline composition controls (4xx)

const (
	// ISSUE-422: Workflow has no explicit `name:` field
	CodeAnonymousDefinition ErrorCode = "ISSUE-422"
	// ISSUE-418: Workflow has no `concurrency:` block at either level
	CodeMissingConcurrency ErrorCode = "ISSUE-418"
	// ISSUE-419: Workflow uses a misfeature pattern (shell: cmd, inline pip install curl|sh, …)
	CodeWorkflowMisfeature ErrorCode = "ISSUE-419"
	// ISSUE-420: Workflow script contains obfuscation (zero-width / non-ASCII unicode, bidi)
	CodeWorkflowObfuscation ErrorCode = "ISSUE-420"
	// ISSUE-421: PyPI / npm publish relies on a static token instead of OIDC trusted publishing
	CodeUseTrustedPublishing ErrorCode = "ISSUE-421"
	// ISSUE-423: A file under .github/workflows cannot be parsed as a workflow
	CodeInvalidWorkflow ErrorCode = "ISSUE-423"
	// ISSUE-901: dependabot.yml re-enables insecure external code execution
	CodeDependabotInsecureExec ErrorCode = "ISSUE-901"
	// ISSUE-902: dependabot.yml update ecosystem has no cooldown window
	CodeDependabotMissingCooldown ErrorCode = "ISSUE-902"
	// ISSUE-903: Repository has workflows but no dependency update tool configured
	CodeDependencyUpdateToolMissing ErrorCode = "ISSUE-903"
	// ISSUE-904: Repository has workflows but none runs a SAST scanner
	CodeSASTWorkflowMissing ErrorCode = "ISSUE-904"
	// ISSUE-905: Repository has workflows but no SECURITY.md policy file
	CodeSecurityPolicyMissing ErrorCode = "ISSUE-905"
)

Issue codes for workflow-hygiene controls (4xx, plus the 9xx repo-hygiene codes)

const (
	// ISSUE-501: Branch is not protected
	CodeBranchUnprotected ErrorCode = "ISSUE-501"
	// ISSUE-502: A merge-request approval rule covering all protected branches requires fewer approvals than the configured minimum
	CodeMRApprovalRulesBelowMinimum ErrorCode = "ISSUE-502"
	// ISSUE-503: The project's merge-request approval settings do not meet the configured expectations
	CodeMRApprovalSettingsNonCompliant ErrorCode = "ISSUE-503"
	// ISSUE-504: No merge-request approval rule applies to all protected branches
	CodeMRApprovalRulesAllBranchesMissing ErrorCode = "ISSUE-504"
	// ISSUE-505: Branch has non-compliant protection settings
	CodeBranchNonCompliant ErrorCode = "ISSUE-505"
	// ISSUE-506: The project's merge-request/merge settings do not meet the configured expectations
	CodeMRSettingsNonCompliant ErrorCode = "ISSUE-506"
	// ISSUE-803: Job runs with overly broad permissions (write-all)
	CodeExcessivePermissions ErrorCode = "ISSUE-803"
)

Issue codes for access and authorization controls (5xx)

const (
	// ISSUE-601: No (or the wrong) GitLab security policy project is linked
	CodeSecurityPolicyProjectNotSet ErrorCode = "ISSUE-601"
)

Issue codes for security-policy-source controls (6xx)

func CodesForControl added in v0.4.29

func CodesForControl(controlName string) []ErrorCode

CodesForControl returns every ISSUE code registered to a control name, sorted for deterministic output. Empty for an unknown control name.

func (ErrorCode) DocURL added in v0.1.67

func (c ErrorCode) DocURL() string

DocURL returns the documentation URL for a given issue code.

func (ErrorCode) String added in v0.1.67

func (c ErrorCode) String() string

String returns the string representation of an issue code.

type ErrorCodeInfo added in v0.1.67

type ErrorCodeInfo struct {
	// Code is the unique issue code (e.g., ISSUE-102).
	Code ErrorCode `json:"code"`
	// Severity reflects potential impact (see documentation); used for Plumber Score.
	Severity IssueSeverity `json:"severity"`
	// Title is a short human-readable title used by SARIF / GLSAST / JSON
	// renderers when no provider-specific override is set. Acts as the
	// default; the catalog DisplayName covers the terminal renderer's
	// per-provider naming separately.
	Title string `json:"title"`
	// Description explains what the issue is. Same fallback semantics as
	// Title: rendered as-is when no provider override is set.
	Description string `json:"description"`
	// Remediation provides guidance on how to fix the issue.
	Remediation string `json:"remediation"`
	// NestedRemediation replaces Remediation on a finding whose reference a
	// composite action or a called workflow pulled in (its data carries via,
	// spec 2026-10-16 section 6): the user cannot edit the container, so the
	// fix is on the outermost action. Empty when the remediation applies
	// either way.
	NestedRemediation string `json:"nestedRemediation,omitempty"`
	// DocURL is a direct link to the documentation for this issue.
	DocURL string `json:"docUrl"`
	// ControlName is the .plumber.yaml control key this code belongs to.
	ControlName string `json:"controlName"`
	// Role decides how a finding of this code enters the score (spec: contextual
	// score). EntryKind is set for RoleEntry only and names the situation fact
	// the finding anchors to.
	Role      CodeRole  `json:"role"`
	EntryKind EntryKind `json:"entryKind,omitempty"`

	// TitleByProvider lets cross-provider controls render different titles
	// in SARIF / JSON / GLSAST depending on which provider produced the
	// finding (mirrors the per-provider DisplayName already supported by
	// the catalog). Keyed by "gitlab" / "github". An empty map (the common
	// case) means TitleFor() falls back to Title for every provider.
	TitleByProvider map[string]string `json:"titleByProvider,omitempty"`
	// DescriptionByProvider works the same way for the longer description.
	DescriptionByProvider map[string]string `json:"descriptionByProvider,omitempty"`
}

ErrorCodeInfo provides metadata about an issue code.

func AllCodes added in v0.1.67

func AllCodes() []ErrorCodeInfo

AllCodes returns all registered issue codes sorted by code.

func LookupCode added in v0.1.67

func LookupCode(code ErrorCode) *ErrorCodeInfo

LookupCode returns the ErrorCodeInfo for a given issue code, or nil if not found.

func (ErrorCodeInfo) DescriptionFor added in v0.3.42

func (i ErrorCodeInfo) DescriptionFor(provider string) string

DescriptionFor mirrors TitleFor for the description.

func (ErrorCodeInfo) RemediationFor added in v0.7.2

func (i ErrorCodeInfo) RemediationFor(nested bool) string

RemediationFor is the remediation a finding of this code shows: the nested variant for a finding pulled in through a container when the code has one, the code's own otherwise.

func (ErrorCodeInfo) TitleFor added in v0.3.42

func (i ErrorCodeInfo) TitleFor(provider string) string

TitleFor returns the title to render for a finding emitted under the given provider ("gitlab" / "github"). Falls back to Title if no override is registered for the provider, so codes that don't need per-provider divergence stay a single Title declaration.

type FactState added in v0.5.19

type FactState string

FactState is the confidence a situation fact was derived with: "proven" when the pipeline data settles it, "unknown" when it cannot be decided statically, "absent" when the fact does not hold.

const FactUnknown FactState = "unknown"

FactUnknown is the state of a fact the facts layer could not decide statically: who controls what runs, whether a cache is restored, how complete the secret list is, and the like.

func (FactState) Claim added in v0.7.1

func (s FactState) Claim() ClaimState

Claim folds a FactState to the ClaimState it stands for: proven stays proven, unknown stays unknown. A FactState the facts layer never emits as settled or unknown (absent) has no claim to fold to and returns ClaimUnknown, the safe side of the fold.

type GitHubAnalysisStats added in v0.3.0

type GitHubAnalysisStats struct {
	// Actions pinning (ISSUE-701).
	ActionRefsTotal    int
	ActionRefsUnpinned int
	ActionRefsExempt   int
	// ActionRefsImmutableRelease lists the tag refs ISSUE-701 accepted
	// because the tag is backed by an immutable GitHub release (#421):
	// not unpinned, and listed so the report says why a ref that looks
	// unpinned passed.
	ActionRefsImmutableRelease []PinnedRef
	// WorkflowLockfilePath is the scanned branch's workflow lockfile
	// (".github/workflows/actions.lock"), empty when it has none.
	WorkflowLockfilePath string
	// ActionRefsWorkflowLockfile lists the step refs ISSUE-701 accepted
	// because that lockfile locks them (#542). Reusable-workflow calls and
	// the steps of another repository's reusable workflow never appear:
	// lockfiles do not cover reusable workflows.
	ActionRefsWorkflowLockfile []PinnedRef
	// ActionRefsNested counts the entries a composite action or a called
	// workflow pulled in (monorepo spec 2026-10-16 section 8), trusted
	// owners included: they are part of the closure the rules read.
	ActionRefsNested int

	// Actions supply-chain (ISSUE-702, ISSUE-703, ISSUE-402). Counted
	// across every `uses:` entry that has API metadata, regardless of
	// the pin-by-SHA trusted-owner exemption — the rules themselves do
	// not exempt. In practice trusted-owner refs have nil metadata
	// (enrichment skips them) and won't add to either count.
	ActionRefsArchived   int
	ActionRefsVulnerable int
	ActionRefsAmbiguous  int
	// ActionRefsAbsentUpstream (ISSUE-707) counts SHA-pinned refs the
	// upstream repo confirmed do not exist. Guarded on isShaPinned so it
	// stays aligned with the impostor-commit rego, which only fires on a
	// 40-char SHA.
	ActionRefsAbsentUpstream int

	// Container images (ISSUE-102 / ISSUE-103).
	ImagesTotal          int
	ImagesPinnedByDigest int
	ImagesUsingForbidden int
	// ImagesExemptUnpinned counts the exemptImages matches without a
	// digest: not pinned, yet not "Not Pinned By Digest" either, since
	// ISSUE-103 does not judge them.
	ImagesExemptUnpinned int
	// ImagesExempted lists the references of the exempt images whose tag
	// is forbidden, the ones the exemption let through ISSUE-102.
	ImagesExempted []string

	// Docker-in-Docker (ISSUE-412 / ISSUE-413).
	JobsTotal              int
	JobsWithDinD           int
	JobsWithInsecureDaemon int

	// Excessive permissions (ISSUE-803). Counts jobs whose effective
	// `permissions:` is the literal `write-all` shortcut (workflow-
	// level grants are propagated per-job by the collector, so the
	// per-job count is the right denominator either way).
	JobsWithWriteAll int

	// Debug trace (ISSUE-203, GitHub side). VariableBindingsTotal is
	// the env-var denominator displayed by the stats block; mirrors
	// the GitLab side's "Variables Checked". DebugTraceFound counts
	// (job, var) pairs where the variable name matches a configured
	// forbidden entry case-insensitively and the value is truthy.
	VariableBindingsTotal int
	DebugTraceFound       int

	// Reusable workflow secrets (ISSUE-302).
	ReusableCalls               int
	ReusableCallsSecretsInherit int

	// Security jobs (ISSUE-410).
	SecurityJobsTotal    int
	SecurityJobsWeakened int

	// Workflow content scanned for template injection (ISSUE-207).
	ScriptLinesTotal int

	// Unverified script execution (ISSUE-411).
	UnverifiedScriptsFound int

	// CacheWriteExempted lists the exempted jobs (exemptWorkflows,
	// exemptJobs) that ISSUE-705 would have flagged: granted cache write on
	// an untrusted trigger, accepted as a risk (ISSUE-705).
	CacheWriteExempted []string

	// Workflows + properties (ISSUE-802, ISSUE-801).
	WorkflowsTotal                int
	WorkflowsWithDangerousTrigger int
	WorkflowsMissingPermissions   int

	// WorkflowFilesInvalid (ISSUE-423) counts the GitHub workflow files
	// this scan fetched but could not parse as a workflow. These files
	// are not among WorkflowsTotal (never parsed into a job).
	WorkflowFilesInvalid int

	// Branch protection (ISSUE-501 / ISSUE-505).
	BranchesTotal     int
	BranchesProtected int
	BranchesMatched   int // matched a configured namePattern
	// BranchesProtectionDetailsUnknown counts in-scope branches whose
	// protection-detail fetch did not yield authoritative data
	// (typically: GitHub /branches/{name}/protection 403/404 because
	// the token lacks Administration:Read). The branch_non_compliant
	// rule (ISSUE-505) abstains on such branches; this counter exists
	// so the renderer can surface a "we couldn't fully evaluate this"
	// caveat instead of a misleading 100% compliant.
	BranchesProtectionDetailsUnknown int
}

GitHubAnalysisStats holds per-control aggregations computed by AggregateGitHubStats from the normalized pipeline IR. Each field corresponds to a denominator/numerator pair used by the renderer to display "(X.X% compliant)" headers and stats blocks like the GitLab side. All fields are pre-aggregated counts — the renderer does not walk the IR again.

func AggregateGitHubStats added in v0.3.0

func AggregateGitHubStats(pipeline *ir.NormalizedPipeline, pc *configuration.PlumberConfig) *GitHubAnalysisStats

AggregateGitHubStats walks the normalized pipeline IR once and produces the per-control denominators the GitHub renderer needs to emit "(X.X% compliant)" headers and stats blocks (Total Images: 19, Pinned By Digest: 1, …) — matching the GitLab output style.

All counts are derived from the IR; no second collection pass is needed. Configuration knobs (trustedOwners, forbiddenTags, security-job-pattern list) are read from pc when present, falling back to sensible defaults so the function works on a bare config.

type ImpactFact added in v0.5.19

type ImpactFact struct {
	Kind     string    `json:"kind"` // publishes | deploys | writes_repo | signs_or_releases
	State    FactState `json:"state"`
	Evidence string    `json:"evidence"`
	// Source is "token" when the impact is what the job token's write scope
	// lets any code in the job do, empty when a step of the job does it.
	Source string `json:"source,omitempty"`
	// Via names the job of a reusable workflow whose step does it, when the
	// job holding the impact is the one calling that workflow; empty for
	// the job's own steps and token.
	Via string `json:"via,omitempty"`
}

ImpactFact is something the job can change, and how sure the facts layer is that it does.

type IncludeFact added in v0.6.0

type IncludeFact struct {
	Subject string   `json:"subject"`
	File    string   `json:"file,omitempty"`
	Line    int      `json:"line,omitempty"`
	Jobs    []string `json:"jobs"`
}

IncludeFact is one pipeline include: the subject a job-less include finding names ("source@ref"), where the include is declared, and the jobs it shapes.

type IssueSeverity added in v0.1.83

type IssueSeverity string

IssueSeverity is the documented severity for an issue code (aligned with getplumber.io issue docs).

const (
	SeverityCritical IssueSeverity = "critical"
	SeverityHigh     IssueSeverity = "high"
	SeverityMedium   IssueSeverity = "medium"
	SeverityLow      IssueSeverity = "low"
)

func ContextualSeverity added in v0.6.0

func ContextualSeverity(f opaengine.Finding, paths []AttackPath, defaultBranch string) IssueSeverity

ContextualSeverity is what the run shows for the finding: its strongest path's tier when it anchors one or more, the strongest walked path's tier for a privilege-role finding on one (spec section 2, Findings on no path; strongestPath, the same selection FindingLine and v4Counts use, so the three can never disagree), its individual severity otherwise (IndividualSeverity, read against the pipeline's default branch): a finding on no path is priced at it.

func IndividualSeverity added in v0.6.0

func IndividualSeverity(f opaengine.Finding, defaultBranch string) IssueSeverity

IndividualSeverity is what a finding on no path is priced and labelled at: its registry severity, except a missing branch protection on a branch other than the default one (High; Critical on the default branch, or when the default branch is unknown), an action or a GitLab component from an unauthorized source pinned by a full commit SHA (Medium; High otherwise) and a GitLab function from an unauthorized source pinned by an image digest (Medium; High otherwise). The policy says so on the finding (Data "occurrenceSeverity"), which is read when it holds a severity; the same rule is computed here for a finding that carries none.

func SeverityForCode added in v0.1.83

func SeverityForCode(code ErrorCode) IssueSeverity

SeverityForCode returns the documented severity for a code, or medium if unknown.

type JobSituation added in v0.5.19

type JobSituation struct {
	// ForkPR is the job's fork pull request trigger fact (at most one per
	// job, a union when same-named jobs merge): GitHub "on: pull_request"
	// without a same-repository guard, GitLab "rules: merge_request_event".
	// No control reports it; the platform reach rules read it.
	ForkPR []EntryFact `json:"forkPR"`
	// PrivilegedTriggers is the job's events that run in the base
	// repository's context with its secrets and write token
	// (pull_request_target, workflow_run, issue_comment and the like),
	// sorted. A fork run of a job carrying one keeps its privilege; the
	// platform reach rules read it.
	PrivilegedTriggers []string `json:"privilegedTriggers"`
	// RefTriggers is the job's events that run it on a branch or a tag of
	// the repository itself (push, schedule, release, workflow_dispatch),
	// sorted: such a run saves its cache in that ref's scope, never only in
	// a pull request's.
	RefTriggers []string `json:"refTriggers"`
	// Push is the job's push to the default branch fact, present while the
	// branch is not known to be protected; its state is unknown when the
	// trigger filter or the protection cannot be decided. A
	// branch-protection finding on that branch starts its path here.
	Push      []EntryFact  `json:"push"`
	Privilege Privilege    `json:"privilege"`
	Impact    []ImpactFact `json:"impact"`
	Feeds     []string     `json:"feeds"`
	// FeedsVia is, per job of Feeds, the kinds of edge that link the two,
	// sorted: FeedArtifact, FeedArtifactAltered, FeedArtifactExecuted,
	// FeedCache, FeedOutput, FeedOutputCompared, FeedOutputExecuted. On
	// GitHub, needs output reads and artifact downloads never carry the
	// bare FeedArtifact or FeedOutput any more (see the two typed kinds
	// of each); GitLab keeps both, needs: alone being how an artifact
	// download there waits for its upload too.
	FeedsVia map[string][]string `json:"feedsVia"`
	// Caches is every cache the job restores, saves or both, with the step
	// declaring it, sorted.
	Caches []CacheFact `json:"caches"`
	// CacheScopes is the refs GitHub files what the job's runs save to the
	// cache under, sorted: "default" (the default branch: a push to it, a
	// schedule, a dispatch, and the other events that run there when the
	// job's cache-mode grants them write), "tag", "pull_request",
	// "merge_group", "branch" (a push to other branches only), or "any"
	// when the job carries no trigger (GitLab). Empty when its runs save
	// nothing (cache-mode read or none, or only low-trust events). Only a
	// "default" (or "any") save reaches a run of another ref.
	CacheScopes []string `json:"cacheScopes"`
	// RestoresCache is set when the job declares a cache it restores and
	// its cache-mode leaves restoring on (not write-only or none): a job a
	// poisoned cache reaches.
	RestoresCache bool `json:"restoresCache"`
	// SaveScopesByEvent is, per event the job runs on (its gated
	// triggers), the scopes a run of that event saves the cache under,
	// empty when GitHub keeps that run read-only: CacheScopes split by
	// event, so an entry that holds only in some runs (an untrusted
	// expression whose context only some events carry) saves only where
	// those runs do. Empty when the job carries no trigger (GitLab).
	SaveScopesByEvent map[string][]string `json:"saveScopesByEvent,omitempty"`
	// Uploads is true when some job carrying the name produces an
	// artifact (ir.ArtifactRef Mode "produce": an actions/upload-artifact
	// or actions/upload-pages-artifact step on GitHub, an artifacts:
	// block on GitLab). With a cache save (CacheFact Mode "save" or
	// "both") it is what lets code on the runner poison something a
	// later run reads: without either, the runner is thrown away.
	Uploads bool `json:"uploads"`
	// Callers is, on a job of a reusable workflow of the repository, every
	// call that runs it, sorted. The job's own Privilege already reads
	// through them (its token the union of theirs when it declares none,
	// its secrets those a call passes); a path entering the job names, per
	// caller, what that call gives.
	Callers []CallerFact `json:"callers"`
	// OwnImage is true when the job runs in a container and every image it
	// resolves to (through its matrix, or the image each caller of a
	// reusable workflow passes) is under the repository owner's namespace of
	// the GitHub container registry (ghcr.io/<owner>/...): an image of the
	// repository's organization, not an outside one.
	OwnImage bool `json:"ownImage"`
	// Dead is true when every job carrying the name has a constant false
	// if: (ir.Job.Dead) or a non-empty Inert reason: it never runs, so no
	// path enters it, and it is no feed edge either way.
	Dead bool `json:"dead"`
	// Inert is why this job can never run (ir.Job.Inert, MarkInert): its
	// workflow is disabled, its runner label is retired, it has no
	// trigger, it is a reusable workflow nobody calls, or it is pinned
	// to another repository. Empty for a job that can run; a job sharing
	// its name with a non-inert job still reads one of the group's own
	// non-empty reasons here (never cleared by a sibling that can run),
	// so the terminal report still names the cause.
	Inert string `json:"inert"`
	// Gate is who can make the job run on its privileged triggers
	// (ir.Job.Gate, ir.ClassifyGates), the weakest among the jobs sharing
	// its name; nil when none carries a privileged trigger, and on GitLab.
	Gate *ir.Gate `json:"gate,omitempty"`
}

JobSituation is the full fact set of one job (or of every job sharing its name, merged).

type OtherFindingsLoss added in v0.6.0

type OtherFindingsLoss struct {
	Count        int            `json:"count"`
	Counts       SeverityCounts `json:"counts"`
	UncappedLoss float64        `json:"uncappedLoss"`
	Cap          float64        `json:"cap"`
	CappedLoss   float64        `json:"cappedLoss"`
	CapApplied   bool           `json:"capApplied"`
}

OtherFindingsLoss is what the findings no path consumes cost: one price per distinct identity at its registry severity, spent worst first within Cap (CapApplied when the prices add up to more), then, outside Critical, within the nested cap they share with the paths. CappedLoss is what they take off once both caps held.

type PathBlock added in v0.6.0

type PathBlock struct {
	Tier         PathTier
	Unverified   bool
	Loss         float64
	Entry        string // "image docker.io/alpine:latest"
	EntrySubject string // the part of Entry read off the workflow: "docker.io/alpine:latest"
	// Branches is one per entry job of the path, in the path's job order.
	Branches []PathBlockBranch
	// EntryUnverified and ReachUnverified say what could not be checked:
	// about the entry, or about what it reaches. Empty when there is
	// nothing to say.
	EntryUnverified string
	RunsIn          string // "job est_file" | "jobs build, deploy": the entry jobs
	ReachesShort    string
	ReachUnverified string
	So              string
	// Cap says, on a path a cap lowered (the push entry cap, the
	// dependency cap), why it stops at its tier (Medium or High); empty
	// otherwise. It is how the score was computed, not what the path is,
	// so a graph prints it as a Note only when ShowCap is set, which the
	// renderers set under --score-point.
	Cap     string
	ShowCap bool
	// Environment is what the block says about the environment the path's
	// entry job runs in: its name, then that it requires a review, when it
	// does. A protection, never an impact.
	Environment []string
	Fix         string
	Findings    []PathBlockFinding
	// ClaimsNote is what Plumber assumed and what it could not read, named
	// by the subjects of the path's own assumed and unknown claims
	// (claimsNote): "" when every claim
	// (claimsFor) is proven. Notes() folds it onto the cap note, after a
	// period, when ShowCap prints one; otherwise it opens a Note line of
	// its own.
	ClaimsNote string
}

PathBlock is one attack path read out as a graph (spec section 4), the content the terminal and the merge request comment both render (PathGraph). Every value is a name already visible in the workflow or the settings, never a value.

func NewPathBlock added in v0.6.0

func NewPathBlock(p AttackPath, findings []opaengine.Finding) PathBlock

NewPathBlock reads p out as a block; findings supply the anchoring and amplifying findings listed under it (nil lists none).

func (PathBlock) Clean added in v0.6.0

func (b PathBlock) Clean(clean func(string) string) PathBlock

Clean returns the block with every text passed through clean: a renderer strips what its output must never carry (an escape sequence on a terminal) before laying the block out.

func (PathBlock) Notes added in v0.6.0

func (b PathBlock) Notes() []string

Notes is what the block says under its consequence: what could not be checked about the entry, what could not be checked about the reach, the environment the job runs in, then, only when ShowCap is set (the points are printed), why the path stops at its cap. ClaimsNote (what Plumber could not read, named by the unknown claims' own subjects) folds onto the cap note, after a period, when ShowCap prints one; every other existing note (the entry, the reach, the environment) is its own line, untouched, so ClaimsNote opens a line of its own instead.

type PathBlockBranch added in v0.6.0

type PathBlockBranch struct {
	Job    string
	Fed    []string
	FedVia [][]string
	Reach  string
	Marker string // "(on pull_request_target)", "(unverified)", both, or empty
}

PathBlockBranch is one branch of a path block: the entry job, the jobs it feeds and, beside each (FedVia, in Fed order), what it hands them (shownFeedKinds), what an attacker there reaches in a few words (the table's phrase), and a marker saying the trigger it runs on or that it is unverified, never how the score was computed (a cap that held it).

type PathBlockFinding added in v0.6.0

type PathBlockFinding struct {
	Code     ErrorCode
	Title    string
	Location string
	Count    int
}

PathBlockFinding is one finding listed under a path block: its code, its registry title, and where it is. Count is above 1 when the line folds that many privilege findings of one code, and Location is then empty.

type PathBranch added in v0.6.0

type PathBranch struct {
	Job       string    `json:"job"`
	Jobs      []string  `json:"jobs"`
	Reach     Reach     `json:"reach"`
	Tier      PathTier  `json:"tier"`
	State     PathState `json:"state"`
	Modifiers []string  `json:"modifiers"`
	// FeedsVia is, per job of Jobs the entry job feeds, the kinds of edge
	// that link them (JobSituation.FeedsVia); absent when it feeds none.
	FeedsVia map[string][]string `json:"feedsVia,omitempty"`
	// contains filtered or unexported fields
}

PathBranch is one entry job of a path: the jobs walked from it (the entry job, then the jobs it feeds), what an attacker there reaches, and the tier, state and modifiers that reach gives once the tier rules applied. A job's attacker code never gets what another entry job holds, so each branch is priced on its own reach.

func (PathBranch) MarshalJSON added in v0.6.0

func (b PathBranch) MarshalJSON() ([]byte, error)

MarshalJSON writes jobs and modifiers as arrays, never null.

type PathLoss added in v0.6.0

type PathLoss struct {
	Tier       PathTier `json:"tier"`
	Count      int      `json:"count"`
	Weight     float64  `json:"weight"`
	Cap        float64  `json:"cap,omitempty"` // omitted when infinite (critical)
	CappedLoss float64  `json:"cappedLoss"`
	PathIDs    []string `json:"pathIds"`
}

type PathRow added in v0.6.0

type PathRow struct {
	Entry   string // "docker.io/alpine:latest (mutable image tag)"
	Jobs    string // "build" | "build, deploy"
	Reaches string // "7 secrets; token: write"
}

PathRow is a path as one row of the attack paths table: its entry, the jobs it runs in, and what it reaches.

func PathRowOf added in v0.6.0

func PathRowOf(p AttackPath) PathRow

PathRowOf reads p out as a table row; the entry is what identifies the row, in the words of the block's Entry line (<subject> (<nature>)).

type PathState added in v0.6.0

type PathState string

PathState says whether every fact on the path is settled (proven), or at least one of them could not be decided statically (unverified).

const (
	PathProven     PathState = "proven"
	PathUnverified PathState = "unverified"
)

type PathTier added in v0.6.0

type PathTier string

PathTier is how severe an attack path is, independent of the state (proven/unverified) it was assembled with.

const (
	TierCritical PathTier = "critical"
	TierHigh     PathTier = "high"
	TierMedium   PathTier = "medium"
	TierLow      PathTier = "low"
)

type PinnedRef added in v0.7.11

type PinnedRef struct {
	Job       string
	Uses      string
	Line      int
	PinnedVia string
}

PinnedRef is one `uses:` reference (a step's, or a job-level reusable-workflow call's) that ISSUE-701 accepted as pinned without a commit SHA, PinnedVia saying why. Line is 0 for a reusable-workflow call.

type PipelineImageMetricsSummary

type PipelineImageMetricsSummary struct {
	Total uint `json:"total"`
}

PipelineImageMetricsSummary is a simplified version of image metrics for output

type PipelineOriginMetricsSummary

type PipelineOriginMetricsSummary struct {
	JobTotal            uint `json:"jobTotal"`
	JobHardcoded        uint `json:"jobHardcoded"`
	OriginTotal         uint `json:"originTotal"`
	OriginComponent     uint `json:"originComponent"`
	OriginLocal         uint `json:"originLocal"`
	OriginProject       uint `json:"originProject"`
	OriginRemote        uint `json:"originRemote"`
	OriginTemplate      uint `json:"originTemplate"`
	OriginGitLabCatalog uint `json:"originGitLabCatalog"`
	OriginOutdated      uint `json:"originOutdated"`
}

PipelineOriginMetricsSummary is a simplified version of origin metrics for output

type PlatformPolicyLine added in v0.4.60

type PlatformPolicyLine struct {
	Name        string
	Enforcement string
	Letter      string
	FinalPoints int
	Blocking    bool
}

PlatformPolicyLine is one resolved policy's row in the merge-request comment's table. Letter is empty and FinalPoints zero for a policy the CLI could not evaluate; the renderer prints that as "not evaluated" rather than as a score of zero. Blocking is the platform's own gate answer for this policy, never a CLI recomputation.

type PlatformPostSummary added in v0.4.60

type PlatformPostSummary struct {
	GlobalLetter string
	GlobalPoints int
	HasGlobal    bool
	Policies     []PlatformPolicyLine
}

PlatformPostSummary is what a platform-mode run tells the post-analysis actions (the GitLab badge and the merge-request comment) about the platform's verdict, in place of the run-level Plumber Score they read for a standalone run. There is no run-level score in platform mode: every verdict belongs to one of the platform's policies, and the single headline figure is the platform's own global score from the push response (spec 2026-09-10-cli-platform-mode-policies-only s5).

HasGlobal is the honest flag rather than an empty GlobalLetter: a push that returned no global score is a routine state (an older platform, a fail-open gate), and the comment must say "score unavailable" while the badge stays on its last good value. Neither may fall back to a locally computed figure the platform never agreed to (QUESTIONS row 44).

type PlumberScoreResult added in v0.1.83

type PlumberScoreResult struct {
	ProfileID string `json:"profileId"`

	Counts SeverityCounts `json:"counts"`

	// RawPoints is 100 minus summed capped per-code losses (before Critical malus).
	RawPoints float64 `json:"rawPoints"`
	// RawPointsUnclamped is RawPoints before the floor at zero. RawPoints is
	// what the gate and the badge are defined on, so it stays clamped; this
	// carries the true signed deficit for consumers that store history and
	// want to distinguish "barely failing" from "catastrophic".
	RawPointsUnclamped float64 `json:"rawPointsUnclamped"`
	// FinalPoints applies Critical category malus (max points in E band when any Critical exists).
	FinalPoints float64 `json:"finalPoints"`
	// Score is the letter A–E from final points (what people mean by "how did we score?").
	Score string `json:"score"`

	CriticalMalusApplied bool    `json:"criticalMalusApplied"`
	CriticalMalusMax     float64 `json:"criticalMalusMax,omitempty"` // max points when malus applies (30)

	// Losses is a per-severity rollup of capped per-code losses.
	Losses []SeverityLoss `json:"losses"`
	// CodeLosses is the per-code breakdown that drives the score in scoring-v3;
	// under scoring-v4 it lists the other findings per code, each code's
	// CappedLoss its share of the capped bucket.
	CodeLosses []CodeLoss `json:"codeLosses"`

	// Paths is the assembled attack-path set scoring-v4 priced.
	Paths []AttackPath `json:"paths,omitempty"`
	// PathLosses is the attack-path bucket, one entry per tier present.
	PathLosses []PathLoss `json:"pathLosses,omitempty"`
	// OtherFindings is the other-findings bucket, nil when every live
	// finding is consumed by a path.
	OtherFindings *OtherFindingsLoss `json:"otherFindings,omitempty"`
	// FloorApplied and FloorPoints are kept for the consumers that read
	// them and are never set: the contextual score has no floor, its nested
	// caps keep a run with no Critical item at 31 or more, with no High at
	// 51 or more, with no Medium at 71 or more.
	FloorApplied bool    `json:"floorApplied,omitempty"`
	FloorPoints  float64 `json:"floorPoints,omitempty"`
	// CriticalPaths is how many assembled paths are Tier Critical; > 0 is
	// what drives scoring-v4's own Critical malus (CriticalMalusApplied),
	// a gate alone never does.
	CriticalPaths int `json:"criticalPaths,omitempty"`
	// PathCounts is how many assembled paths each tier holds, keyed
	// critical/high/medium/low, non-zero tiers only (spec section 4: the
	// PBOM carries it); nil when no path assembled.
	PathCounts map[string]int `json:"pathCounts,omitempty"`
	// Situation is the situation facts as text (SituationText): visibility,
	// the default branch, the jobs. Empty under scoring-v3.
	Situation string `json:"situation,omitempty"`
	// BestFix is the single highest-value fix ScoreV4WithExplanations found
	// among the run's non-dismissed findings, nil when none gains points.
	BestFix *BestFix `json:"bestFix,omitempty"`
}

PlumberScoreResult is the official result: letter Score (A–E) derived from numeric Points (0–100).

func ComputePlumberScore added in v0.1.83

func ComputePlumberScore(codeCounts map[ErrorCode]int) PlumberScoreResult

ComputePlumberScore applies the scoring-v3 rules (see docs/scoring.md).

For each issue code with count n > 0:

loss = w × (1 + 0.5·log2(n))   (capped at the per-severity cap)

Each ErrorCode is treated as its own bucket: distinct codes at the same severity each consume their own per-code cap, so accumulating different types of issues keeps reducing the score even after one code is capped.

Raw points are 100 minus the sum of capped per-code losses. When at least one Critical issue is present, final points are capped at 30 (Critical malus), forcing the letter score into the E band. The A–E letter is read from final points using the thresholds in ScoreLetterFromPoints.

func ComputePlumberScoreV4 added in v0.6.0

func ComputePlumberScoreV4(in ScoreInputV4) PlumberScoreResult

ComputePlumberScoreV4 applies the contextual score (spec section 2, Points): 100 minus the attack paths, each at a flat price by tier, and the other findings, every live finding no path consumes (not an anchor, not an amplifying gate, not a privilege finding on a path's surviving jobs) at its registry price within 30 spent worst first. Critical items count in full; every other item, path or finding, enters one nested cap by severity (nestLosses). A Critical path then caps the final points. No floor applies: FloorApplied and FloorPoints stay empty.

func ComputeScoreForProfile added in v0.6.0

func ComputeScoreForProfile(profile string, result *AnalysisResult) PlumberScoreResult

ComputeScoreForProfile runs the formula the profile names. "v4" assembles the attack paths, prices them, and fills the situation paragraph and the best fix (ScoreV4WithExplanations), recording the paths on the result for the outputs that read them. "v3" runs the per-code formula over the same findings, unchanged. Any other value falls back to v3: the cmd layer validates the flag before this is ever called with something else.

A "v4" request over a run whose situation facts never evaluated falls back to v3 as well (spec section 2, Points: withholding rules; invariant I3: never a fake verdict). Pricing every entry finding as hygiene because there is no situation to walk would hand out a fake-good score, not an honest "could not evaluate" one, so this never prices anything as v4 without a real situation to price it against.

The run-level score and every per-policy score (ReEvaluateForConfig) go through here alike, so the v3-fallback decision is made in one place.

func ScoreV4WithExplanations added in v0.6.0

func ScoreV4WithExplanations(result *AnalysisResult) PlumberScoreResult

ScoreV4WithExplanations is the scoring-v4 entry point, for the run-level score and for every per-policy score alike (ComputeScoreForProfile calls it for both): it assembles the attack paths onto result, prices them (ComputePlumberScoreV4), then adds the best fix and the situation paragraph.

type Privilege added in v0.5.19

type Privilege struct {
	Secrets        []string  `json:"secrets"`
	SecretsState   FactState `json:"secretsState"`
	SecretsInherit bool      `json:"secretsInherit"`
	// AllSecrets is true when the job holds every secret of the repository,
	// proven by the way it reads them (secrets: inherit, or the whole
	// secrets context), whatever their names.
	AllSecrets       bool     `json:"allSecrets"`
	ProtectedSecrets []string `json:"protectedSecrets"`
	TokenWrite       []string `json:"tokenWrite"`
	TokenWriteSource string   `json:"tokenWriteSource"` // declared | log | effect | unknown | none
	// TokenWriteState is how settled TokenWriteSource is: "proven" for
	// declared, log and effect (a real declaration, a run's own log, or
	// a bot-authored write all settle it), "unknown" for unknown and
	// none (nothing settled it either way).
	TokenWriteState      FactState `json:"tokenWriteState"`
	PersistedCredentials FactState `json:"persistedCredentials"`
	// Environment is the job's deployment environment, a protection the job
	// runs behind and never an impact by itself. Reviewers is "proven" once
	// the provider's environments were read authoritatively, "unknown"
	// while they were not collected (GitLab) or the collection failed
	// (GitHub 401/403/404/network). RequiredReviewers and Rules are only
	// meaningful when Reviewers is "proven": RequiredReviewers is whether
	// the matching environment requires an approval before the job runs;
	// Rules names which protection rules that environment carries
	// (required_reviewers, wait_timer, branch_policy).
	Environment struct {
		Name              string    `json:"name"`
		Protected         string    `json:"protected"` // "true" | "false" | "unknown"
		Reviewers         FactState `json:"reviewers"` // "proven" | "unknown"
		RequiredReviewers bool      `json:"requiredReviewers"`
		Rules             []string  `json:"rules"`
	} `json:"environment"`
}

Privilege is what the job holds: the secrets it can read, the token write scopes it has, and whether it persists credentials to disk.

type Reach added in v0.6.0

type Reach struct {
	Secrets []string `json:"secrets"` // names reachable by the attacker, after platform rules
	// AllSecrets is true when a walked job holds every secret of the
	// repository (Privilege.AllSecrets), after platform rules.
	AllSecrets bool         `json:"allSecrets"`
	TokenWrite []string     `json:"tokenWrite"` // write scopes reachable by the attacker, after platform rules
	Impacts    []ImpactFact `json:"impacts"`    // impact facts of the walked jobs, concatenated
	// Executes is true for every path: a push runs the job's own steps, and
	// a job with no script of its own (a bare `uses:` push trigger, for
	// instance) still runs whatever that uses target executes. The field
	// stays (the outputs read it, and a future entry kind may need to turn
	// it off) but under the current entry kinds it is always true.
	Executes bool `json:"executes"`
	// contains filtered or unexported fields
}

Reach is what an attacker who walks in through a path's entry can touch: the privilege held by the walked jobs, and the impact they can cause.

type ScoreInputV4 added in v0.6.0

type ScoreInputV4 struct {
	Findings []opaengine.Finding
	Paths    []AttackPath
	// ProjectPath is the analysed repository's "owner/repo", which the
	// best fix re-assembles the paths for (AssemblePaths); empty when
	// unknown.
	ProjectPath string
	// DefaultBranch is the pipeline's default branch, which a finding on
	// no path is priced against (IndividualSeverity); empty when unknown.
	DefaultBranch string
}

ScoreInputV4 is everything the scoring-v4 formula reads: the assembled attack paths (control.AssemblePaths) and every finding the run produced, dismissed or not.

type SeverityCounts added in v0.1.83

type SeverityCounts struct {
	Critical int `json:"critical"`
	High     int `json:"high"`
	Medium   int `json:"medium"`
	Low      int `json:"low"`
}

SeverityCounts is the number of detected issues per documented severity bucket.

func SeverityCountsFromIssueCodes added in v0.1.83

func SeverityCountsFromIssueCodes(codes []ErrorCode) SeverityCounts

SeverityCountsFromIssueCodes tallies severities for individual findings (one code per finding).

type SeverityLoss added in v0.1.83

type SeverityLoss struct {
	Severity   IssueSeverity `json:"severity"`
	Count      int           `json:"count"`
	CappedLoss float64       `json:"cappedLoss"`
}

SeverityLoss is a rollup per severity bucket: sum of capped losses across all codes in that severity. In scoring-v3 the cap is applied per code, so this rollup can exceed any single per-code cap.

type Situation added in v0.5.19

type Situation struct {
	Exposure string                  `json:"exposure"` // ir.Visibility*
	Jobs     map[string]JobSituation `json:"jobs"`
	// DefaultBranch is the pipeline's default branch (input.pipeline.defaultBranch
	// on the Rego side), empty when the provider never gave it. A gate whose
	// own branch matches this one amplifies every path, not only paths
	// through jobs it shares (spec section 2, rule 7).
	DefaultBranch string `json:"defaultBranch"`
	// Provider is the pipeline's provider, "github" or "gitlab".
	Provider string `json:"provider"`
	// Includes is every pipeline include with a source.
	Includes []IncludeFact `json:"includes"`
}

Situation is the whole facts layer for one pipeline: the repository's exposure plus every job's facts, keyed by job name.

func DecodeSituation added in v0.5.19

func DecodeSituation(raw json.RawMessage) (*Situation, error)

DecodeSituation parses data.situation.result. Missing jobs decode to an empty map so callers never nil-check.

func EvaluateSituation added in v0.5.19

func EvaluateSituation(ctx context.Context, pipeline *ir.NormalizedPipeline, config map[string]any) (*Situation, error)

EvaluateSituation runs the situation module on the pipeline. It returns an empty, unknown-exposure situation together with the error when the module cannot run: the score then degrades to "no path found" and the caller records the warning (I3: never a fake verdict, but never a crash either).

type SituationFact added in v0.6.0

type SituationFact struct {
	Label string `json:"label"`
	Value string `json:"value"`
}

SituationFact is one labelled line of the situation block that closes the report (spec section 3).

func SituationFacts added in v0.6.0

func SituationFacts(sit *Situation, pipeline *ir.NormalizedPipeline) []SituationFact

SituationFacts reads the repository and the pipeline out as labelled facts: visibility, the default branch and its protection, and the jobs with how many publish or deploy. Every value is a name or a count, never an evidence value.

type StatLine added in v0.3.65

type StatLine struct {
	Label string
	Value string
}

StatLine is a single labelled metric rendered above a control's findings block in the terminal output. Label is the display name; Value is a pre-formatted string (plain int, percentage, …).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL