diff --git a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json index 1186fa12b..5b59c521b 100644 --- a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json @@ -74,6 +74,17 @@ "items": { "$ref": "#/definitions/fingerprint" } + }, + "survivors": { + "type": "array", + "description": "Commits that survived Phase-1 triage — the adjudication work queue, retained after draining as the coverage record. Present on an un-adjudicated (triage-only) or incrementally-built context; absent for a combined triage+adjudicate scan.", + "items": { + "$ref": "#/definitions/survivor" + } + }, + "discarded": { + "$ref": "#/definitions/sha_list", + "description": "Commits Phase-1 triage classified and REJECTED — the other half of the triage record, SHAs only because a discard carries nothing else worth recording. With survivors and scan.unresolved it states the full set of commits ever handed to the classifier, so a later walk that re-covers this ground can skip it instead of paying for it again. Absent on a context whose producer did not record it." } }, "definitions": { @@ -310,6 +321,41 @@ "type": "integer", "minimum": 0, "description": "Holes the list cap dropped, so a truncated list still reports an honest total" + }, + "adjudicated_commits": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Commit SHAs driven to a terminal adjudication state across all runs (the adjudication frontier)." + }, + "survivors_total": { + "type": "integer", + "minimum": 0, + "description": "How many survivors the context holds at the covered window." + }, + "pending_survivors": { + "type": "integer", + "minimum": 0, + "description": "How many survivors still await adjudication — survivors that are neither in the frontier, nor a triage hole, nor abandoned after the retry cap. A count rather than a list because the list is already determined by survivors and adjudicated_commits; zero alongside adjudication_complete is the drained queue." + }, + "triage_budget_hit": { + "type": "boolean", + "description": "True when the most recent triage run stopped because it reached its survivor budget (the --max-new-survivors cap) rather than because it exhausted its window, which means there is known-untriaged history immediately behind scan.window.from_sha. Cleared by a later run that exhausts its window without hitting the budget. Not a statement about the adjudication queue — that is pending_survivors/adjudication_complete — and not the same as \"the window does not reach the repository root\": a --last-bounded run does not either, and that question needs git rather than this artifact." + }, + "triage_input_tokens": { + "type": "integer", + "minimum": 0, + "description": "Cumulative Phase-1 input tokens across every triage run." + }, + "triage_output_tokens": { + "type": "integer", + "minimum": 0, + "description": "Cumulative Phase-1 output tokens across every triage run." + }, + "adjudication_complete": { + "type": "boolean", + "description": "True when every survivor (outside holes/abandoned) reached a terminal state — the trust signal that an empty fingerprints list is 'clean' rather than 'not adjudicated yet'." } } }, @@ -354,6 +400,47 @@ } } }, + "survivor": { + "type": "object", + "title": "Survivor", + "description": "One commit that survived Phase-1 triage: an entry in the adjudication work queue the context carries. All but commit_sha are optional.", + "required": [ + "commit_sha" + ], + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": "string", + "description": "The commit that triage marked as a candidate for adjudication" + }, + "parent_sha": { + "type": "string", + "description": "Parent of the survivor commit. Empty for a root commit." + }, + "commit_date": { + "type": "string", + "description": "Committer date of the survivor commit" + }, + "subject": { + "type": "string", + "description": "Subject line of the survivor commit" + }, + "patch_id": { + "type": "string", + "description": "git patch-id --stable; rebase-durable key for history-rewrite reconciliation. Empty for merge commits." + }, + "diff_bytes": { + "type": "integer", + "minimum": 0, + "description": "Size of the survivor's normalised diff in bytes" + }, + "attempts": { + "type": "integer", + "minimum": 0, + "description": "Failed adjudication tries; at the cap the survivor is abandoned." + } + } + }, "top_risk": { "type": "object", "title": "Top risk", diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 2d6e14b9d..8b915b636 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -286,6 +286,10 @@ func TestValidateSecurityContext(t *testing.T) { name: "valid security context", filePath: "./testdata/ai_security_context_valid.json", }, + { + name: "valid un-adjudicated (triage-only) context", + filePath: "./testdata/ai_security_context_triage_only.json", + }, { name: "missing required fields", filePath: "./testdata/ai_security_context_missing_required.json", @@ -317,6 +321,109 @@ func TestValidateSecurityContext(t *testing.T) { } } +// TestValidateSecurityContextTriageFields covers the optional fields the +// triage/adjudicate split consolidated into the security context in place at +// security-context-0.1: the top-level survivors queue and discard record, and +// the scan_stats counters (adjudicated_commits, survivors_total, +// pending_survivors, triage_budget_hit, triage_input_tokens, +// triage_output_tokens, adjudication_complete). An un-adjudicated (triage-only) +// context carries survivors, discards and the triage counters, a +// fully-adjudicated context carries the adjudication frontier, a combined-scan +// context omits them all, and a genuinely unknown field is still rejected — so +// both the context object and scan_stats keep their additionalProperties: false +// contract. +func TestValidateSecurityContextTriageFields(t *testing.T) { + load := func(t *testing.T) (map[string]any, map[string]any) { + t.Helper() + f, err := os.ReadFile("./testdata/ai_security_context_valid.json") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(f, &payload)) + + scan, ok := payload["scan"].(map[string]any) + require.True(t, ok, "the fixture must carry a scan object") + return payload, scan + } + + t.Run("an un-adjudicated context with survivors and triage counters validates", func(t *testing.T) { + payload, scan := load(t) + payload["survivors"] = []any{ + map[string]any{ + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "parent_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "commit_date": "2026-08-19T19:58:28-03:00", + "subject": "Avoid shell invocation in command handler", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "diff_bytes": 451, + "attempts": 0, + }, + } + payload["discarded"] = []any{ + "1b8f5aa595c0953995c40e92b1669282ba76dd08", + "c8533df53b0af4b731cb1036ec61aee10e35c67b", + } + scan["survivors_total"] = 1 + scan["pending_survivors"] = 1 + // Budget-stopped rather than window-exhausted: known-untriaged history sits + // immediately behind scan.window.from_sha. + scan["triage_budget_hit"] = true + scan["triage_input_tokens"] = 8883 + scan["triage_output_tokens"] = 83 + scan["adjudication_complete"] = false + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("an adjudicate-produced context validates", func(t *testing.T) { + payload, scan := load(t) + scan["adjudicated_commits"] = []any{"8c948c742bdfc09c4aae6b3c386faeb98f925ff2"} + scan["survivors_total"] = 1 + // The drained queue: the producer emits the zero rather than omitting it, so + // "nothing pending" is stated rather than inferred from an absent field. + scan["pending_survivors"] = 0 + scan["adjudication_complete"] = true + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("a discard list of short SHAs is rejected", func(t *testing.T) { + payload, _ := load(t) + payload["discarded"] = []any{"1b8f5aa"} + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "pattern") + }) + + t.Run("a negative pending_survivors is rejected", func(t *testing.T) { + payload, scan := load(t) + scan["pending_survivors"] = -1 + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "minimum") + }) + + t.Run("a combined-scan context validates without them", func(t *testing.T) { + payload, _ := load(t) + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("a survivor missing its required commit_sha is rejected", func(t *testing.T) { + payload, _ := load(t) + payload["survivors"] = []any{map[string]any{"subject": "no sha"}} + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "missing properties") + }) + + t.Run("an unknown survivor field is rejected", func(t *testing.T) { + payload, _ := load(t) + payload["survivors"] = []any{map[string]any{ + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "unexpected_key": "x", + }} + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties") + }) + + t.Run("an unknown scan_stats field is still rejected", func(t *testing.T) { + payload, scan := load(t) + scan["unexpected_field"] = "x" + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties") + }) +} + func TestValidateOpenAPI(t *testing.T) { testCases := []struct { name string diff --git a/internal/schemavalidators/testdata/ai_security_context_triage_only.json b/internal/schemavalidators/testdata/ai_security_context_triage_only.json new file mode 100644 index 000000000..663a3e9fc --- /dev/null +++ b/internal/schemavalidators/testdata/ai_security_context_triage_only.json @@ -0,0 +1,85 @@ +{ + "schema_version": "security-context-0.1", + "generated_at": "2026-09-10T19:34:26Z", + "repo": { + "owner": "chainloop-dev", + "name": "sample-repo-go", + "url": "https://github.com/chainloop-dev/sample-repo-go", + "ref": "fe5eb735", + "head_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "", + "triage_model": "openai/gpt-5.6-luna:nitro", + "triage_prompt_id": "current-diff-only-v1", + "input_profile": "D0" + }, + "scan": { + "window": { + "from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f", + "to_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b" + }, + "commits_scanned": 11, + "commits_triaged": 11, + "commits_skipped": 0, + "triage_candidates": 3, + "adjudicated": 0, + "findings": 0, + "abstained": 0, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 0, + "anchors_relocated": 0, + "anchors_rejected": 0, + "input_tokens": 0, + "output_tokens": 0, + "wall_clock_s": 0, + "reconciles": true, + "triage_input_tokens": 10930, + "triage_output_tokens": 417, + "adjudication_complete": false, + "survivors_total": 3, + "pending_survivors": 3, + "triage_budget_hit": true + }, + "survivors": [ + { + "commit_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b", + "parent_sha": "af5f2f747ea110af304d1feb9c0d88073005441e", + "commit_date": "2026-09-10T16:26:54-03:00", + "subject": "fix: reject absolute paths in the ls endpoint", + "patch_id": "cec8571ff951f25c91ba4581690b1c22018ed2f8", + "diff_bytes": 522 + }, + { + "commit_sha": "ff57f5cd0d76d4b02483d0c89a6c931557156ccb", + "parent_sha": "72a1590ff2f099f67b78a2cd47ea6dbb78a88a43", + "commit_date": "2026-09-10T16:24:44-03:00", + "subject": "fix: reject directory traversal in the ls endpoint", + "patch_id": "81e8ce4bae5aea1d2001440d9440d7dfc1e5f95d", + "diff_bytes": 510 + }, + { + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "parent_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "commit_date": "2026-08-19T19:58:28-03:00", + "subject": "Avoid shell invoction in command handler", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "diff_bytes": 451 + } + ], + "discarded": [ + "0d7a7bd3a2f0f8b1c6e4a9f27b35d8e1c4a60f92", + "3b9c1f04e7a25d8c6b0f31e9a47d52c8f6013abd", + "a1f4c70e2b8d95a36c1e47f0b92d58ac6304e7f1" + ], + "class_counts": {}, + "min_support": 2, + "top_risks": [], + "shared_surfaces": [], + "fingerprints": [] +} diff --git a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go index ae2c27af9..74eec2b5b 100644 --- a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go +++ b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go @@ -105,6 +105,53 @@ type ScanStats struct { DuplicatesMerged int `json:"duplicates_merged,omitempty"` Unresolved []Unresolved `json:"unresolved,omitempty"` TruncatedUnresolved int `json:"truncated_unresolved,omitempty"` + + // AdjudicatedCommits are the commit SHAs driven to a terminal Phase-2 state + // across all runs — the adjudication frontier. + AdjudicatedCommits []string `json:"adjudicated_commits,omitempty"` + // SurvivorsTotal is how many survivors the context holds at the covered + // window. + SurvivorsTotal int `json:"survivors_total,omitempty"` + // PendingSurvivors is how many survivors still await adjudication: neither in + // the frontier, nor a triage hole, nor abandoned after the retry cap. Zero + // alongside AdjudicationComplete is the drained queue. + PendingSurvivors int `json:"pending_survivors,omitempty"` + // TriageBudgetHit is true when the most recent triage run stopped because it + // reached its survivor budget (the --max-new-survivors cap) rather than + // because it exhausted its window, so known-untriaged history sits immediately + // behind Scan.Window.FromSHA. Cleared by a later run that exhausts its window + // without hitting the budget. It says nothing about the adjudication queue, + // and is not the same as a window that does not reach the repository root — a + // --last-bounded run does not either, and only git can answer that. + TriageBudgetHit bool `json:"triage_budget_hit,omitempty"` + // TriageInputTokens is the cumulative Phase-1 input tokens across every + // triage run. + TriageInputTokens int64 `json:"triage_input_tokens,omitempty"` + // TriageOutputTokens is the cumulative Phase-1 output tokens across every + // triage run. + TriageOutputTokens int64 `json:"triage_output_tokens,omitempty"` + // AdjudicationComplete is true when every survivor (outside holes/abandoned) + // reached a terminal state — the signal that an empty fingerprints list is + // "clean" rather than "not adjudicated yet". + AdjudicationComplete bool `json:"adjudication_complete,omitempty"` +} + +// Survivor is one commit that survived Phase-1 triage: an entry in the +// adjudication work queue the context carries. Retained after draining as the +// coverage record. All but CommitSHA are optional. +type Survivor struct { + CommitSHA string `json:"commit_sha"` + ParentSHA string `json:"parent_sha,omitempty"` + CommitDate string `json:"commit_date,omitempty"` + Subject string `json:"subject,omitempty"` + // PatchID is git patch-id --stable: a rebase-durable key for history-rewrite + // reconciliation. Empty for merge commits. + PatchID string `json:"patch_id,omitempty"` + // DiffBytes is the size of the survivor's normalised diff in bytes. + DiffBytes int `json:"diff_bytes,omitempty"` + // Attempts counts failed adjudication tries; at the cap the survivor is + // abandoned. + Attempts int `json:"attempts,omitempty"` } // TopRisk is a component with a security-fix history, ranked by severity mass @@ -267,6 +314,18 @@ type Data struct { TopRisks []TopRisk `json:"top_risks"` SharedSurfaces []SharedSurface `json:"shared_surfaces"` Fingerprints []Fingerprint `json:"fingerprints"` + + // Survivors is the adjudication work queue — the commits that survived + // Phase-1 triage, retained after draining as the coverage record. Present on + // an un-adjudicated (triage-only) or incrementally-built context; absent for + // a combined triage+adjudicate scan. + Survivors []Survivor `json:"survivors,omitempty"` + + // Discarded are the commits Phase-1 triage classified and REJECTED — SHAs + // only, because a discard carries nothing else worth recording. Together with + // Survivors and Scan.Unresolved it states the full set of commits ever handed + // to the classifier. + Discarded []string `json:"discarded,omitempty"` } // Evidence is the Chainloop material envelope around a security context. diff --git a/pkg/attestation/crafter/materials/chainloop_ai_security_context.go b/pkg/attestation/crafter/materials/chainloop_ai_security_context.go index 8ddc0aa3b..c68c7ce97 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_security_context.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_security_context.go @@ -35,6 +35,16 @@ var ( annotationSecurityContextHeadSHA = api.CreateAnnotation("material.securitycontext.head_sha") annotationSecurityContextFingerprints = api.CreateAnnotation("material.securitycontext.fingerprints") annotationSecurityContextReconciles = api.CreateAnnotation("material.securitycontext.reconciles") + // A context is INCOMPLETE when another run over the same HEAD would still make + // progress: survivors are queued but not adjudicated, or triage stopped on its + // survivor budget and left history inside its window un-examined. Published as + // one derived boolean rather than leaving it to be assembled from the two + // underlying fields, so a consumer can filter on it — an annotation query is an + // exact key/value match and cannot express "pending_survivors > 0". + annotationSecurityContextIncomplete = api.CreateAnnotation("material.securitycontext.incomplete") + // The two facts behind it, published for observability rather than filtering. + annotationSecurityContextPendingSurvivors = api.CreateAnnotation("material.securitycontext.pending_survivors") + annotationSecurityContextTriageBudgetHit = api.CreateAnnotation("material.securitycontext.triage_budget_hit") ) type ChainloopAISecurityContextCrafter struct { @@ -140,6 +150,15 @@ func (c *ChainloopAISecurityContextCrafter) annotate(material *api.Attestation_M // the scan is incomplete and must not be read as a clean result. Published so // a policy can reject it without reading the payload. material.Annotations[annotationSecurityContextReconciles] = strconv.FormatBool(data.Scan.Reconciles) + + // Whether more work remains on this HEAD, and the two facts that decide it. An + // incomplete context is not a failed one: the bounded cold-start run publishes + // exactly this state on purpose, so that whatever schedules scans can tell + // "still catching up" from "done" without downloading the payload. + incomplete := data.Scan.PendingSurvivors > 0 || data.Scan.TriageBudgetHit + material.Annotations[annotationSecurityContextIncomplete] = strconv.FormatBool(incomplete) + material.Annotations[annotationSecurityContextPendingSurvivors] = strconv.Itoa(data.Scan.PendingSurvivors) + material.Annotations[annotationSecurityContextTriageBudgetHit] = strconv.FormatBool(data.Scan.TriageBudgetHit) } // annotateTool publishes the scanner through the shared material-tool diff --git a/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go b/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go index 245701618..34fe2b372 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go @@ -428,3 +428,58 @@ func mustMarshal(t *testing.T, v any) []byte { require.NoError(t, err) return b } + +// TestChainloopAISecurityContextCrafter_IncompleteAnnotation covers the annotation +// that says whether another run over the same HEAD would still make progress. +// +// It is published as one derived boolean because an annotation query is an exact +// key/value match: a consumer cannot express "pending_survivors > 0", so it could +// not otherwise distinguish a bounded cold-start context that is still catching up +// from a finished one without downloading the payload. +func TestChainloopAISecurityContextCrafter_IncompleteAnnotation(t *testing.T) { + testCases := []struct { + name string + path string + incomplete string + pendingSurvivors string + budgetHit string + }{ + { + // The fully-adjudicated fixtures carry neither a pending queue nor a + // bounded walk, so nothing more is to be done on this HEAD. + name: "a drained context is complete", + path: "./testdata/ai-security-context.json", + incomplete: "false", + pendingSurvivors: "0", + budgetHit: "false", + }, + { + name: "a minimal drained context is complete", + path: "./testdata/ai-security-context-minimal.json", + incomplete: "false", + pendingSurvivors: "0", + budgetHit: "false", + }, + { + // A real bounded cold-start artifact: triage stopped on its survivor + // budget after 36 of 498 commits, and 5 of the 10 survivors it queued are + // still un-adjudicated. Both reasons to keep going are present at once. + name: "a bounded cold-start context is incomplete", + path: "./testdata/ai-security-context-incomplete.json", + incomplete: "true", + pendingSurvivors: "5", + budgetHit: "true", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := craftedMaterial(newSecurityContextCrafter(t).Craft(context.TODO(), tc.path)) + require.NoError(t, err) + + assert.Equal(t, tc.incomplete, got.Annotations[annotationSecurityContextIncomplete]) + assert.Equal(t, tc.pendingSurvivors, got.Annotations[annotationSecurityContextPendingSurvivors]) + assert.Equal(t, tc.budgetHit, got.Annotations[annotationSecurityContextTriageBudgetHit]) + }) + } +} diff --git a/pkg/attestation/crafter/materials/testdata/ai-security-context-incomplete.json b/pkg/attestation/crafter/materials/testdata/ai-security-context-incomplete.json new file mode 100644 index 000000000..36d99e495 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/ai-security-context-incomplete.json @@ -0,0 +1,859 @@ +{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_SECURITY_CONTEXT", + "schema": "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json", + "data": { + "schema_version": "security-context-0.1", + "generated_at": "2026-09-11T04:34:35Z", + "repo": { + "owner": "chainloop-dev", + "name": "chainloop", + "url": "https://github.com/chainloop-dev/chainloop", + "ref": "main", + "head_sha": "d7124adef46e2500aa922a2fe330774ef7ea97bc" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "63e96c315cf910401005c33cb60769d07b443017", + "to_sha": "d7124adef46e2500aa922a2fe330774ef7ea97bc", + "last_n": 500 + }, + "commits_scanned": 36, + "commits_triaged": 36, + "commits_skipped": 1, + "triage_candidates": 10, + "adjudicated": 5, + "findings": 2, + "abstained": 3, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 19, + "anchors_relocated": 7, + "anchors_rejected": 0, + "input_tokens": 1261684, + "output_tokens": 21675, + "wall_clock_s": 71.306087083, + "reconciles": true, + "triage_input_tokens": 357276, + "triage_output_tokens": 3288, + "pending_survivors": 5, + "triage_budget_hit": true, + "adjudication_complete": false, + "adjudicated_commits": [ + "0a3859a50dec0490758576dd384383fd909710ab", + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "3846e43d3309e511ae0da02c1a4c5c3f2b45818b", + "5effbcae2ecc173c6d9cfb13a0f068be6aab26d4", + "f55111806027eddffd6fe48cd8715fe88f010d07" + ], + "survivors_total": 10, + "unresolved": [ + { + "sha": "5e57a62d9c66803e45bdd9f707b44cc420ec57e2", + "reason": "diff too large (599136 bytes)" + } + ] + }, + "survivors": [ + { + "commit_sha": "f55111806027eddffd6fe48cd8715fe88f010d07", + "parent_sha": "151ee6a12e1d8200d428b1270622823dd72e616d", + "commit_date": "2026-09-10T13:10:03+02:00", + "subject": "feat(cli): ask for the organization and project on trace init (#3418)", + "patch_id": "7cd647b3d06903872b82b100ffaf044782f89fbc", + "diff_bytes": 288658 + }, + { + "commit_sha": "5effbcae2ecc173c6d9cfb13a0f068be6aab26d4", + "parent_sha": "003d344d7599b6e75893b432be0bc516a8baa27d", + "commit_date": "2026-09-09T13:53:02+02:00", + "subject": "fix: upgrade extras/dagger grpc to v1.83.2 (#3422)", + "patch_id": "8d0cab3edca83abaa43aa54e3a0991ab82015827", + "diff_bytes": 1709 + }, + { + "commit_sha": "3846e43d3309e511ae0da02c1a4c5c3f2b45818b", + "parent_sha": "5c634eb8772e1abd42a9d077c5c024e2686fdfb1", + "commit_date": "2026-09-09T11:12:31+02:00", + "subject": "chore(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 (#3420)", + "patch_id": "a9aae8f6abc33db18908f35343d8885027b47824", + "diff_bytes": 1524 + }, + { + "commit_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "parent_sha": "1008f8216a9eebf089292146e78a3d088d78d84e", + "commit_date": "2026-09-08T17:35:47+02:00", + "subject": "fix(plugins): guard integration plugins against SSRF (#3407)", + "patch_id": "2d6b33f3e2054d421bfb9332d9e26cf57e8453d5", + "diff_bytes": 106471 + }, + { + "commit_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "parent_sha": "195701189daa1de3295031af7cb2d9cf12692a54", + "commit_date": "2026-09-08T08:14:54+02:00", + "subject": "fix(controlplane): cap the policy evaluations inlined by the workflow-run View API (#3408)", + "patch_id": "c45358367fb967255b61701125f819e38b7dd405", + "diff_bytes": 155516 + }, + { + "commit_sha": "e2828c6ea2cd4f5374f7b38f1d330433bb94b417", + "parent_sha": "e2dde78e4de970d69536faf24c90757eddc7c399", + "commit_date": "2026-09-04T22:21:50+02:00", + "subject": "fix(controlplane): enforce project-scoped RBAC on AttestationService/GetContract (#3401)", + "patch_id": "f755d3b8ec75906b1dbbbd5db14a50ed57ce1bdb", + "diff_bytes": 12324 + }, + { + "commit_sha": "06f546626637e0e173c93344bc91ff8c4935d50d", + "parent_sha": "39ede64f2c26cb866191502779812c998a50d77b", + "commit_date": "2026-09-04T20:07:18-03:00", + "subject": "fix: bump golang.org/x/crypto to v0.56.0 (#3403)", + "patch_id": "ce8eba562c6c1776eaefc347a76ccbc1f994e32e", + "diff_bytes": 1571 + }, + { + "commit_sha": "e2dde78e4de970d69536faf24c90757eddc7c399", + "parent_sha": "d2c4dd0d6256d3caaae15144c2c49d21d41e76c3", + "commit_date": "2026-09-04T18:37:47+02:00", + "subject": "fix(controlplane): enforce project RBAC on CAS download redirect lookups (PFM-6716) (#3400)", + "patch_id": "5d07fdf5f893b236c1b5e98347e59e9f669cf8d3", + "diff_bytes": 15248 + }, + { + "commit_sha": "4afdffb6e7be1526cfa5865260b720c4f1efc234", + "parent_sha": "809a484920ff040f04c11c3bfba831e381dd9cd7", + "commit_date": "2026-09-04T17:27:29+02:00", + "subject": "fix(cli): fail attestation verify when the bundle was never verified (#3398)", + "patch_id": "b771ed1268afb9f27010fde40d13538594973235", + "diff_bytes": 30079 + }, + { + "commit_sha": "3fe8434663b93b3077767d84e61223ebb00968b5", + "parent_sha": "25d45af1812f3a272c2ce364264491cb8472237d", + "commit_date": "2026-09-01T16:06:22+02:00", + "subject": "fix(policies): evaluate redacted materials against the content that was stored (#3383)", + "patch_id": "26bbdbaf3527ccb21da3b936eab41148f8a0918f", + "diff_bytes": 158912 + } + ], + "discarded": [ + "003d344d7599b6e75893b432be0bc516a8baa27d", + "1008f8216a9eebf089292146e78a3d088d78d84e", + "151ee6a12e1d8200d428b1270622823dd72e616d", + "195701189daa1de3295031af7cb2d9cf12692a54", + "1a6bbe680f62bc3efb031771ba36e02ead68d1d8", + "25d45af1812f3a272c2ce364264491cb8472237d", + "27afd6b45761765b2cc5d477d9a189eadcaee4ac", + "28d5c7d65a6fc2751fac8323a6bda164915dbef3", + "294cad656b333f873add717aae984c26cb5195dc", + "35cd46cd5065504e5aca79a4d0a77f4e76b40fbc", + "374a4ca17b97bb2492eb7a991ff013827b12a0a6", + "39ede64f2c26cb866191502779812c998a50d77b", + "59535d51fdc3a9ce4d28fede427cb685593eb3fb", + "5c634eb8772e1abd42a9d077c5c024e2686fdfb1", + "5fb81799e8b0199e5e4af4e11885da1bc53595c4", + "6037210405abf31b02fcb904e3069831b5d73d55", + "63e96c315cf910401005c33cb60769d07b443017", + "809a484920ff040f04c11c3bfba831e381dd9cd7", + "83360743bcb412b221bc80565f8bca0132290591", + "8ad6aa6196425287fac4a62c37812466d2da5c13", + "bc95e6ccb847ca44c5d01e1f9b20083303c96f95", + "cca8c4ee6d80fd80cac8e1de4558d2c1967589cf", + "d2c4dd0d6256d3caaae15144c2c49d21d41e76c3", + "d7124adef46e2500aa922a2fe330774ef7ea97bc", + "d88df06c810b75f56420b2fbd3c814f4fbd0eaa9", + "f3cf2fb5538934bf5e7741f5983f75fbaf6edf1c" + ], + "class_counts": { + "access_control": 1, + "resource_exhaustion": 1 + }, + "min_support": 2, + "top_risks": [ + { + "component": "app/controlplane/internal/conf/controlplane/config/v1/conf.proto", + "kind": "source", + "classes": [ + "access_control", + "resource_exhaustion" + ], + "severity": "high", + "fix_count": 2, + "recurring": true, + "severity_mass": 6, + "recency_weight": 1.99, + "cwe": [ + "CWE-770", + "CWE-918" + ], + "evidence": [ + "0a3859a50dec0490758576dd384383fd909710ab", + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/cmd/main.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/core/dependency-track/v1/client/sbom.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/core/dependency-track/v1/cmd/main.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/core/dependency-track/v1/extension.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/core/discord-webhook/v1/discord.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/core/webhook/v1/webhook.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/plugins.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "component": "app/controlplane/plugins/sdk/readme-generator/main.go", + "kind": "source", + "classes": [ + "access_control" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-918" + ], + "evidence": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Access Control — client.Post(webhookURL (+2 related sinkes)", + "class": "access_control", + "guard": [ + "NewHTTPClient", + "isPubliclyRoutable", + "publicOnlyDialContext" + ], + "guard_kind": "guard_added", + "sink_symbols": [ + "client.Post(webhookURL", + "http.DefaultClient.Do(req)", + "i.client.Do(req)" + ], + "entry_points": [ + "app/controlplane/cmd/main.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.proto", + "app/controlplane/plugins/core/dependency-track/v1/client/sbom.go", + "app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go", + "app/controlplane/plugins/core/dependency-track/v1/cmd/main.go", + "app/controlplane/plugins/core/dependency-track/v1/extension.go", + "app/controlplane/plugins/core/dependency-track/v1/extension_test.go", + "app/controlplane/plugins/core/discord-webhook/v1/discord.go", + "app/controlplane/plugins/core/discord-webhook/v1/discord_test.go", + "app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go", + "app/controlplane/plugins/core/slack-webhook/v1/slack_webhook_test.go", + "app/controlplane/plugins/core/webhook/v1/webhook.go", + "app/controlplane/plugins/core/webhook/v1/webhook_test.go", + "app/controlplane/plugins/plugins.go", + "app/controlplane/plugins/sdk/readme-generator/main.go", + "app/controlplane/plugins/sdk/v1/httpclient.go", + "app/controlplane/plugins/sdk/v1/httpclient_test.go", + "deployment/chainloop/Chart.yaml", + "deployment/chainloop/README.md", + "deployment/chainloop/templates/controlplane/configmap.yaml", + "deployment/chainloop/values.yaml", + "devel/integrations.md" + ], + "check_hint": "An integration URL supplied by a caller must not result in a connection to a non-publicly-routable address; DNS answers, redirects, and proxy routing must not bypass that restriction.", + "support": 1, + "origin_fixes": [ + "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1" + ] + }, + { + "surface": "Resource Exhaustion — bytes.Buffer (+2 related sinkes)", + "class": "resource_exhaustion", + "guard": [ + "boundedBuffer", + "policyEvaluationsMaxInlineBytes", + "tooLargePolicyEvaluations" + ], + "guard_kind": "guard_added", + "sink_symbols": [ + "bytes.Buffer", + "chainloop.PolicyEvaluationsFromBundle", + "s.casClient.Download" + ], + "entry_points": [ + "app/cli/cmd/workflow_workflow_run_describe.go", + "app/cli/cmd/workflow_workflow_run_describe_test.go", + "app/cli/pkg/action/workflow_run_describe.go", + "app/controlplane/api/controlplane/v1/response_messages.pb.go", + "app/controlplane/api/controlplane/v1/response_messages.proto", + "app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts", + "app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.jsonschema.json", + "app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.schema.json", + "app/controlplane/api/gen/jsonschema/controlplane.v1.PolicyEvaluationsRef.jsonschema.json", + "app/controlplane/api/gen/jsonschema/controlplane.v1.PolicyEvaluationsRef.schema.json", + "app/controlplane/cmd/wire_gen.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.proto", + "app/controlplane/internal/service/workflowrun.go", + "app/controlplane/internal/service/workflowrun_test.go", + "app/controlplane/pkg/biz/.mockery.yml", + "app/controlplane/pkg/biz/casclient.go", + "app/controlplane/pkg/biz/casclient_test.go", + "app/controlplane/pkg/biz/mocks/CASClient.go", + "pkg/casclient/.mockery.yml", + "pkg/casclient/casclient.go", + "pkg/casclient/mocks/Downloader.go", + "pkg/casclient/mocks/DownloaderUploader.go" + ], + "check_hint": "WorkflowRunService.View must not download, decode, cache, or inline a policy-evaluation bundle larger than the configured maximum, including when CAS size metadata is inaccurate.", + "support": 1, + "origin_fixes": [ + "0a3859a50dec0490758576dd384383fd909710ab" + ] + } + ], + "fingerprints": [ + { + "id": "fp_12468d68a7", + "patch_id": "2d6b33f3e2054d421bfb9332d9e26cf57e8453d5", + "commit_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "commit_date": "2026-09-08T17:35:47+02:00", + "commit_subject": "fix(plugins): guard integration plugins against SSRF (#3407)", + "class": "access_control", + "cwe": [ + "CWE-918" + ], + "components": [ + "app/controlplane/cmd/main.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.proto", + "app/controlplane/plugins/core/dependency-track/v1/client/sbom.go", + "app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go", + "app/controlplane/plugins/core/dependency-track/v1/cmd/main.go", + "app/controlplane/plugins/core/dependency-track/v1/extension.go", + "app/controlplane/plugins/core/dependency-track/v1/extension_test.go", + "app/controlplane/plugins/core/discord-webhook/v1/discord.go", + "app/controlplane/plugins/core/discord-webhook/v1/discord_test.go", + "app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go", + "app/controlplane/plugins/core/slack-webhook/v1/slack_webhook_test.go", + "app/controlplane/plugins/core/webhook/v1/webhook.go", + "app/controlplane/plugins/core/webhook/v1/webhook_test.go", + "app/controlplane/plugins/plugins.go", + "app/controlplane/plugins/sdk/readme-generator/main.go", + "app/controlplane/plugins/sdk/v1/httpclient.go", + "app/controlplane/plugins/sdk/v1/httpclient_test.go", + "deployment/chainloop/Chart.yaml", + "deployment/chainloop/README.md", + "deployment/chainloop/templates/controlplane/configmap.yaml", + "deployment/chainloop/values.yaml", + "devel/integrations.md" + ], + "reachable_from": [ + "app/controlplane/internal/dispatcher/dispatcher.go", + "app/controlplane/internal/service/integration.go", + "app/controlplane/pkg/authz/authz.go" + ], + "sink_symbols": [ + "client.Post(webhookURL", + "http.DefaultClient.Do(req)", + "i.client.Do(req)" + ], + "guard_symbols": [ + "NewHTTPClient", + "isPubliclyRoutable", + "publicOnlyDialContext" + ], + "sink": "Outbound HTTP requests made by integration plugins to registration-controlled webhook or Dependency-Track instance URLs.", + "fix_kind": "guard_added", + "fix_shape": "Adds a DNS-aware, dial-time public-address allowlist and applies it unconditionally to Slack and Discord, and to generic webhook and Dependency-Track when block_private_targets is enabled.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 8.1, + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L" + }, + "confidence": "high", + "summary": "Guards integration-plugin outbound HTTP requests against SSRF to private and cloud-metadata targets.", + "poc": "A permitted integration registrar could cause the control plane to send HTTP requests to loopback, private, link-local, and cloud-metadata endpoints, including posting workflow attestation/SBOM data to a selected internal endpoint.", + "root_cause": "Integration plugins dereferenced registration-controlled URLs with standard HTTP clients that had no private-address, DNS-rebinding, redirect, or proxy bypass protection.", + "attacker_preconditions": "An authenticated principal granted PolicyRegisteredIntegrationAdd can register one of the affected integrations with an attacker-chosen URL; executing a workflow attachment triggers subsequent requests.", + "invariant": "An integration URL supplied by a caller must not result in a connection to a non-publicly-routable address; DNS answers, redirects, and proxy routing must not bypass that restriction.", + "fix_completeness": "partial", + "anchors": [ + { + "revision": "parent", + "revision_sha": "1008f8216a9eebf089292146e78a3d088d78d84e", + "path": "app/controlplane/plugins/core/webhook/v1/webhook.go", + "start_line": 110, + "end_line": 125, + "quoted_span": "\t// Validate the URL\n\tif err := validateURL(regReq.URL); err != nil {\n\t\ti.Logger.Errorw(\"invalid webhook URL\", \"error\", err, \"url\", regReq.URL)\n\t\treturn nil, fmt.Errorf(\"invalid webhook URL: %w\", err)\n\t}\n\n\t// Optionally, perform a test request to ensure the webhook URL is reachable\n\tif err := i.testWebhookURL(ctx, regReq.URL); err != nil {\n\t\ti.Logger.Errorw(\"unable to reach webhook URL\", \"error\", err, \"url\", regReq.URL)\n\t\treturn nil, fmt.Errorf(\"unable to reach webhook URL: %w\", err)\n\t}\n\n\t// Store the URL in credentials\n\tcredentials := \u0026sdk.Credentials{\n\t\tURL: regReq.URL, // Storing the URL in the URL field\n\t}", + "span_sha256": "3d6f80ad8bebea323bca8822aa3745d2bd1cee255879b153dc0d55afb1b9503d", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "1008f8216a9eebf089292146e78a3d088d78d84e", + "path": "app/controlplane/plugins/core/webhook/v1/webhook.go", + "start_line": 245, + "end_line": 259, + "quoted_span": "\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(payloadBytes))\n\tif err != nil {\n\t\ti.Logger.Errorw(\"failed to create HTTP request\", \"error\", err, \"url\", url)\n\t\treturn fmt.Errorf(\"creating HTTP request: %w\", err)\n\t}\n\n\tfor key, value := range headers {\n\t\treq.Header.Set(key, value)\n\t}\n\n\tresp, err := i.client.Do(req)\n\tif err != nil {\n\t\ti.Logger.Errorw(\"failed to send HTTP request\", \"error\", err, \"url\", url)\n\t\treturn fmt.Errorf(\"sending HTTP request: %w\", err)\n\t}", + "span_sha256": "8df5f6f70c5acd56110873f628ee7981923c313b63a2988cab73b8b090b55f6a", + "verified": true, + "relocated": true + }, + { + "revision": "parent", + "revision_sha": "1008f8216a9eebf089292146e78a3d088d78d84e", + "path": "app/controlplane/plugins/core/dependency-track/v1/extension.go", + "start_line": 108, + "end_line": 116, + "quoted_span": "\t// Validate that the provided configuration is valid\n\tinstance, enableProjectCreation := request.InstanceURI, request.AllowAutoCreate\n\tchecker, err := client.NewIntegration(instance, request.APIKey, enableProjectCreation)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"checking integration: %w\", err)\n\t}\n\n\t// Validate that the provided configuration is valid against the remote service\n\tif err := checker.Validate(ctx); err != nil {", + "span_sha256": "90f4e417b873fac909accfd1a9b6f68d69621bfc3469320f6f3a4a12a840a413", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "1008f8216a9eebf089292146e78a3d088d78d84e", + "path": "app/controlplane/plugins/core/dependency-track/v1/client/sbom.go", + "start_line": 204, + "end_line": 214, + "quoted_span": "func teamPermissionsRequest(host *url.URL, apiKey string) (*teamPermissionsResponse, error) {\n\tapiEndpoint := host.JoinPath(\"/api/v1/team/self\")\n\n\treq, err := http.NewRequest(http.MethodGet, apiEndpoint.String(), nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"X-Api-Key\", apiKey)\n\t// Submit the request\n\tres, err := http.DefaultClient.Do(req)", + "span_sha256": "309bc47c60b3180e6e3f766c0d246f7f7ea8a0fa174617890851bf0d6cf5e668", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/plugins/sdk/v1/httpclient.go", + "start_line": 101, + "end_line": 145, + "quoted_span": "// publicOnlyDialContext wraps dial so that a connection is only made to a\n// publicly routable address.\n//\n// The check runs here, at dial time, rather than against the URL, for two\n// reasons. It sees the address the connection will actually use, so a host\n// name that resolves to an allowed address for a check and to a blocked one\n// for the connection cannot slip through: the dial targets the very IP that\n// was validated. And because every redirect hop opens its own connection,\n// the whole chain is covered, not just the URL the caller supplied.\nfunc publicOnlyDialContext(resolve resolveFunc, dial dialFunc) dialFunc {\n\treturn func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\thost, port, err := net.SplitHostPort(addr)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: malformed address %q\", ErrBlockedTarget, addr)\n\t\t}\n\n\t\tips, err := resolve(ctx, host)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"resolving %q: %w\", host, err)\n\t\t}\n\n\t\tif len(ips) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"%w: %q did not resolve to any address\", ErrBlockedTarget, host)\n\t\t}\n\n\t\t// A host that answers with a mix of public and non-public addresses is\n\t\t// refused outright, so that retrying cannot land on the blocked one.\n\t\tfor _, ip := range ips {\n\t\t\tif !isPubliclyRoutable(ip.IP) {\n\t\t\t\treturn nil, fmt.Errorf(\"%w: %q resolves to non-public address %s\", ErrBlockedTarget, host, ip.IP)\n\t\t\t}\n\t\t}\n\n\t\tvar lastErr error\n\t\tfor _, ip := range ips {\n\t\t\tconn, err := dial(ctx, network, net.JoinHostPort(ip.IP.String(), port))\n\t\t\tif err == nil {\n\t\t\t\treturn conn, nil\n\t\t\t}\n\t\t\tlastErr = err\n\t\t}\n\n\t\treturn nil, lastErr\n\t}\n}", + "span_sha256": "1d8303b26c1354f62e2cb7a887db0e29096d8dd5fa37e1932a6cf16f12a47228", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/plugins/sdk/v1/httpclient.go", + "start_line": 85, + "end_line": 92, + "quoted_span": "\tif opts.PublicTargetsOnly {\n\t\ttransport.DialContext = publicOnlyDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext)\n\n\t\t// Through a proxy the only address this client connects to is the\n\t\t// proxy's own, which leaves the destination unchecked and the guard\n\t\t// above unenforced, so a public-only client never uses one.\n\t\ttransport.Proxy = nil\n\t}", + "span_sha256": "af9c7d6541c07509f86da2dc6426a2a7b736f752dfd00f091ff1eee56b6156ac", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/plugins/core/webhook/v1/webhook.go", + "start_line": 97, + "end_line": 103, + "quoted_span": "\treturn \u0026Integration{\n\t\tFanOutIntegration: base,\n\t\tclient: sdk.NewHTTPClient(sdk.HTTPClientOptions{\n\t\t\tTimeout: perAttemptTimeout,\n\t\t\tPublicTargetsOnly: netPolicy.BlockPrivateTargets,\n\t\t}),\n\t}, nil", + "span_sha256": "5acf301448ecc89d5bd6745539286843d5da81adf66a0bf47dd39eaf2b22cecf", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go", + "start_line": 38, + "end_line": 43, + "quoted_span": "// publicOnlyClient builds the HTTP client used to reach the webhook. Slack is\n// a public service, so a destination inside the deployment's own network is\n// always refused, whatever the deployment's plugin network policy says.\nfunc publicOnlyClient() *http.Client {\n\treturn sdk.NewHTTPClient(sdk.HTTPClientOptions{PublicTargetsOnly: true})\n}", + "span_sha256": "bd5aec63f6843538529d3f4d98ecd77b33efffdb49cfdbaef5382fca67a1b0f1", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "deployment/chainloop/values.yaml", + "start_line": 169, + "end_line": 172, + "quoted_span": " ## @extra controlplane.pluginsNetworkPolicy Outbound network policy for the plugins that accept an arbitrary target URL, currently the generic webhook and Dependency-Track plugins. Plugins whose destination is a known public service, such as the Slack and Discord webhooks, always refuse non-public destinations.\n ## @param controlplane.pluginsNetworkPolicy.blockPrivateTargets Refuse requests to destinations that are not publicly routable, such as loopback, private ranges and cloud metadata endpoints\n pluginsNetworkPolicy:\n blockPrivateTargets: false", + "span_sha256": "4fe10c0db7129f1b254d2eed3a1b9ef524b6c90fd9a9b141ccbbc2bb0cf8c45d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/internal/service/integration.go", + "start_line": 79, + "end_line": 103, + "quoted_span": "func (s *IntegrationsService) Register(ctx context.Context, req *pb.IntegrationsServiceRegisterRequest) (*pb.IntegrationsServiceRegisterResponse, error) {\n\torg, err := requireCurrentOrg(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// lookup the integration\n\tintegration, err := s.integrations.FindByID(req.PluginId)\n\tif err != nil {\n\t\treturn nil, errors.NotFound(\"not found\", err.Error())\n\t}\n\n\ti, err := s.integrationUC.RegisterAndSave(ctx, org.ID, req.Name, req.Description, integration, req.Config)\n\tif err != nil {\n\t\tif biz.IsNotFound(err) {\n\t\t\treturn nil, errors.NotFound(\"not found\", err.Error())\n\t\t} else if biz.IsErrValidation(err) {\n\t\t\treturn nil, errors.BadRequest(\"wrong validation\", err.Error())\n\t\t}\n\n\t\treturn nil, handleUseCaseErr(err, s.log)\n\t}\n\n\treturn \u0026pb.IntegrationsServiceRegisterResponse{Result: bizIntegrationToPb(i)}, nil\n}", + "span_sha256": "1a723ce629bc690799197d49f3847f2650d3265f34dfe6f9a7041bb31479485a", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/internal/dispatcher/dispatcher.go", + "start_line": 311, + "end_line": 315, + "quoted_span": "\treturn backoff.RetryNotify(\n\t\tfunc() error {\n\t\t\tlogger.Infow(\"msg\", \"executing integration\", \"integration\", plugin.String(), \"input\", inputType)\n\n\t\t\terr := plugin.Execute(ctx, opts)", + "span_sha256": "03ccfd44ff740a49e5566663af94b17460866d99e67f01764680ee9df4fc8275", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "12468d68a7e3dd2c47089cb6b298c01ec30e8ab1", + "path": "app/controlplane/pkg/authz/authz.go", + "start_line": 372, + "end_line": 374, + "quoted_span": "\t\"/controlplane.v1.IntegrationsService/ListRegistrations\": {Policies: []*Policy{PolicyRegisteredIntegrationList}},\n\t\"/controlplane.v1.IntegrationsService/DescribeRegistration\": {Policies: []*Policy{PolicyRegisteredIntegrationRead}},\n\t\"/controlplane.v1.IntegrationsService/Register\": {Policies: []*Policy{PolicyRegisteredIntegrationAdd}},", + "span_sha256": "b1affcbd3e54d786b5904e970c9adeff65deedc3dbd188acb9cd4c2fb957520d", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "A principal authorized to register an integration could supply a private or metadata URL that the plugin HTTP client would request.", + "after": "Private targets are unreachable through Slack and Discord registrations; generic webhook and Dependency-Track private targets are unreachable when pluginsNetworkPolicy.blockPrivateTargets is enabled." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "b8be94a351d608cb1a6408ee7dd6919139a3e992", + "description": "Dependency-Track outbound client", + "committed_at": "2023-03-07T22:09:35+01:00", + "verified": true + }, + { + "commit_sha": "59caa39674fe9a6b2fe61cae5de711e22c4fc44c", + "description": "Discord webhook integration", + "committed_at": "2023-06-16T08:24:39+02:00", + "verified": true + }, + { + "commit_sha": "95f814ffcc88babaf625dbd709beb7c7ed7cdfbc", + "description": "Slack webhook integration", + "committed_at": "2023-07-05T00:36:23+02:00", + "verified": true + }, + { + "commit_sha": "88239fc5787b108cbee6aeea0177ecf24231c6ab", + "description": "generic webhook integration", + "committed_at": "2025-01-03T14:42:10+01:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 110658372 + }, + { + "id": "fp_0a3859a50d", + "patch_id": "c45358367fb967255b61701125f819e38b7dd405", + "commit_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "commit_date": "2026-09-08T08:14:54+02:00", + "commit_subject": "fix(controlplane): cap the policy evaluations inlined by the workflow-run View API (#3408)", + "class": "resource_exhaustion", + "cwe": [ + "CWE-770" + ], + "components": [ + "app/cli/cmd/workflow_workflow_run_describe.go", + "app/cli/cmd/workflow_workflow_run_describe_test.go", + "app/cli/pkg/action/workflow_run_describe.go", + "app/controlplane/api/controlplane/v1/response_messages.pb.go", + "app/controlplane/api/controlplane/v1/response_messages.proto", + "app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts", + "app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.jsonschema.json", + "app/controlplane/api/gen/jsonschema/controlplane.v1.AttestationItem.schema.json", + "app/controlplane/api/gen/jsonschema/controlplane.v1.PolicyEvaluationsRef.jsonschema.json", + "app/controlplane/api/gen/jsonschema/controlplane.v1.PolicyEvaluationsRef.schema.json", + "app/controlplane/cmd/wire_gen.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go", + "app/controlplane/internal/conf/controlplane/config/v1/conf.proto", + "app/controlplane/internal/service/workflowrun.go", + "app/controlplane/internal/service/workflowrun_test.go", + "app/controlplane/pkg/biz/.mockery.yml", + "app/controlplane/pkg/biz/casclient.go", + "app/controlplane/pkg/biz/casclient_test.go", + "app/controlplane/pkg/biz/mocks/CASClient.go", + "pkg/casclient/.mockery.yml", + "pkg/casclient/casclient.go", + "pkg/casclient/mocks/Downloader.go", + "pkg/casclient/mocks/DownloaderUploader.go" + ], + "reachable_from": [ + "app/controlplane/api/controlplane/v1/workflow_run.proto", + "app/controlplane/pkg/authz/authz.go" + ], + "sink_symbols": [ + "bytes.Buffer", + "chainloop.PolicyEvaluationsFromBundle", + "s.casClient.Download" + ], + "guard_symbols": [ + "boundedBuffer", + "policyEvaluationsMaxInlineBytes", + "tooLargePolicyEvaluations" + ], + "sink": "Unbounded CAS download into bytes.Buffer followed by caching and decoding in WorkflowRunService.resolvePolicyEvaluations.", + "fix_kind": "guard_added", + "fix_shape": "Caps policy-evaluation bundles inlined by WorkflowRunService.View at 10 MiB by checking CAS metadata and enforcing the same limit on received bytes; oversized or unavailable bundles are returned by reference.", + "severity": { + "level": "medium", + "source": "model_estimate", + "score": 6.5 + }, + "confidence": "high", + "summary": "Fixes an authenticated resource-exhaustion vulnerability in workflow-run View policy-evaluation inlining.", + "poc": "An authorized user could make the control plane allocate and retain an unbounded downloaded bundle, decoded evaluations, regrouped data, and response objects, exhausting process memory and denying service.", + "root_cause": "WorkflowRunService.resolvePolicyEvaluations trusted an unbounded CAS download and materialized its contents several times without a size limit.", + "attacker_preconditions": "An authenticated principal with project-scoped PolicyWorkflowRunRead access to a workflow run whose policy-evaluation CAS bundle is oversized; this can be its own run, and repeated View requests amplify memory pressure.", + "invariant": "WorkflowRunService.View must not download, decode, cache, or inline a policy-evaluation bundle larger than the configured maximum, including when CAS size metadata is inaccurate.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "195701189daa1de3295031af7cb2d9cf12692a54", + "path": "app/controlplane/internal/service/workflowrun.go", + "start_line": 114, + "end_line": 122, + "quoted_span": "\tvar buf bytes.Buffer\n\tif err := s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, \u0026buf, digest); err != nil {\n\t\treturn nil, fmt.Errorf(\"downloading policy eval bundle: %w\", err)\n\t}\n\n\tdata := buf.Bytes()\n\t_ = s.policyEvalCache.Set(ctx, digest, data)\n\n\treturn chainloop.PolicyEvaluationsFromBundle(data)", + "span_sha256": "bcf802c7eeff027ba32b89912a9e7ea24345bc564e615dc6a05c896e60492a26", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "path": "app/controlplane/internal/service/workflowrun.go", + "start_line": 94, + "end_line": 101, + "quoted_span": "// defaultPolicyEvaluationsMaxInlineBytes bounds the policy-evaluation bundle\n// the View API is willing to download and inline in a response. A single\n// attestation can carry a six-figure number of violations, and inlining one\n// holds the payload in memory several times over (download buffer, decoded\n// bundle, regrouped evaluations, response protos), which is enough to exhaust\n// the control plane. Bundles above the cap are returned as a reference so the\n// caller can fetch them directly from the CAS.\nconst defaultPolicyEvaluationsMaxInlineBytes = 10 \u003c\u003c 20 // 10MiB", + "span_sha256": "b22340aefababf7f73dc00ea3e9dd012f6142ecd23e2e80871d37d6760a3d453", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "path": "app/controlplane/internal/service/workflowrun.go", + "start_line": 164, + "end_line": 206, + "quoted_span": "\t// Ask for the size before paying for the transfer.\n\tinfo, err := s.casClient.Describe(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, digest)\n\tif err != nil {\n\t\ts.log.Warnw(\"msg\", \"describing policy evaluations bundle\", \"digest\", digest, \"err\", err)\n\t\treturn unavailablePolicyEvaluations(digest, 0, mediaType)\n\t}\n\n\tif info.Size \u003e maxInlineBytes {\n\t\ts.log.Infow(\"msg\", \"policy evaluations bundle too large to inline\", \"digest\", digest, \"size\", info.Size, \"max\", maxInlineBytes)\n\t\treturn tooLargePolicyEvaluations(digest, info.Size, mediaType)\n\t}\n\n\t// A size of zero means the backend did not report one, not that the object\n\t// is empty: some backends omit the content length and the proto getter then\n\t// yields zero. Downloading on that basis would be downloading blind.\n\tif info.Size \u003c= 0 {\n\t\ts.log.Warnw(\"msg\", \"policy evaluations bundle has no reported size\", \"digest\", digest)\n\t\treturn unavailablePolicyEvaluations(digest, 0, mediaType)\n\t}\n\n\t// The reported size is metadata, so bound the transfer itself as well.\n\t// A backend that under-reports cannot then push us past the cap.\n\tbuf := \u0026boundedBuffer{limit: maxInlineBytes}\n\terr = s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, buf, digest)\n\n\t// Checked before the error because a writer refusing to grow surfaces as a\n\t// download failure, and because the bound must hold even if an\n\t// implementation swallows the write error.\n\tif buf.exceeded {\n\t\ts.log.Warnw(\"msg\", \"policy evaluations bundle exceeded the cap while downloading\", \"digest\", digest, \"reportedSize\", info.Size, \"max\", maxInlineBytes)\n\t\t// The reported size is known to be wrong, so no size is reported at all.\n\t\treturn tooLargePolicyEvaluations(digest, 0, mediaType)\n\t}\n\n\tif err != nil {\n\t\ts.log.Warnw(\"msg\", \"downloading policy evaluations bundle\", \"digest\", digest, \"err\", err)\n\t\treturn unavailablePolicyEvaluations(digest, info.Size, mediaType)\n\t}\n\n\tdata := buf.Bytes()\n\t_ = s.policyEvalCache.Set(ctx, digest, data)\n\n\treturn s.decodePolicyEvaluations(data, digest, info.Size, mediaType)", + "span_sha256": "7c910407148d9cd75ff1c368620dd64f2c8110f5825ef773faee8a6f15bef524", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "path": "app/controlplane/internal/service/workflowrun.go", + "start_line": 209, + "end_line": 230, + "quoted_span": "// boundedBuffer accumulates bytes in memory up to a limit and refuses the write\n// that would exceed it, recording that it did. It exists so the policy\n// evaluations cap is enforced against the bytes actually received rather than\n// against the size the CAS backend claims.\ntype boundedBuffer struct {\n\tbuf bytes.Buffer\n\tlimit int64\n\twritten int64\n\texceeded bool\n}\n\nfunc (b *boundedBuffer) Write(p []byte) (int, error) {\n\tif b.written+int64(len(p)) \u003e b.limit {\n\t\tb.exceeded = true\n\t\treturn 0, fmt.Errorf(\"content exceeds the maximum of %d bytes\", b.limit)\n\t}\n\n\tn, err := b.buf.Write(p)\n\tb.written += int64(n)\n\n\treturn n, err\n}", + "span_sha256": "643ce1cf395d111295b4691171fa4f0b95e1942ddf20ca4047b2723217cda7bb", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "path": "app/controlplane/internal/service/workflowrun.go", + "start_line": 409, + "end_line": 450, + "quoted_span": "\t// Enforce project-scoped RBAC on the workflow run\n\tif err = s.authorizeResource(ctx, authz.PolicyWorkflowRunRead, authz.ResourceTypeProject, run.Workflow.ProjectID); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar verificationResult *pb.WorkflowRunServiceViewResponse_VerificationResult\n\tif req.Verify {\n\t\t// it might be nil if it doesn't apply\n\t\tvr, err := s.wrUseCase.VerifyRun(ctx, run)\n\t\tif err != nil {\n\t\t\treturn nil, handleUseCaseErr(err, s.log)\n\t\t}\n\t\tverificationResult = bizVerificationToPb(vr)\n\t}\n\n\tvar predicate chainloop.NormalizablePredicate\n\tvar policyEvaluationsRef *pb.PolicyEvaluationsRef\n\tif run.Attestation != nil \u0026\u0026 run.Attestation.Envelope != nil {\n\t\tpredicate, err = chainloop.ExtractPredicate(run.Attestation.Envelope)\n\t\tif err != nil {\n\t\t\treturn nil, handleUseCaseErr(err, s.log)\n\t\t}\n\n\t\tif resolved := s.resolvePolicyEvaluations(ctx, predicate.GetPolicyEvaluationsRef(), run.Workflow.OrgID); resolved != nil {\n\t\t\t// Either the evaluations are inlined, or the caller is handed the\n\t\t\t// reference to fetch them from the CAS itself.\n\t\t\tif resolved.ref != nil {\n\t\t\t\tpolicyEvaluationsRef = resolved.ref\n\t\t\t} else {\n\t\t\t\tpredicate = \u0026casResolvedPredicate{NormalizablePredicate: predicate, evals: resolved.evaluations}\n\t\t\t}\n\t\t}\n\t}\n\n\tattestation, err := bizAttestationToPb(run.Attestation, predicate)\n\tif err != nil {\n\t\treturn nil, handleUseCaseErr(err, s.log)\n\t}\n\n\tif attestation != nil {\n\t\tattestation.PolicyEvaluationsRef = policyEvaluationsRef\n\t}", + "span_sha256": "c03a624932e7318db1009af16bfb66b48fb589dde333e099278773c658cfe843", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "path": "app/controlplane/api/controlplane/v1/workflow_run.proto", + "start_line": 44, + "end_line": 47, + "quoted_span": "service WorkflowRunService {\n rpc List(WorkflowRunServiceListRequest) returns (WorkflowRunServiceListResponse);\n rpc View(WorkflowRunServiceViewRequest) returns (WorkflowRunServiceViewResponse);\n}", + "span_sha256": "d7f724aa8c1bb9075b6493de3076c49ab4691b331c2c5ee49d5faab7daeb99a4", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "0a3859a50dec0490758576dd384383fd909710ab", + "path": "app/controlplane/pkg/authz/authz.go", + "start_line": 389, + "end_line": 391, + "quoted_span": "\t// WorkflowRun\n\t\"/controlplane.v1.WorkflowRunService/List\": {Policies: []*Policy{PolicyWorkflowRunList}},\n\t\"/controlplane.v1.WorkflowRunService/View\": {Policies: []*Policy{PolicyWorkflowRunRead}},", + "span_sha256": "5e562df7caa195780cae792017ef99493871ca16251bb933c06d28c403492bea", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "The authorized WorkflowRunService.View RPC downloaded every referenced policy-evaluation bundle into an unbounded bytes.Buffer, then cached, decoded, and inlined it.", + "after": "The View RPC returns a PolicyEvaluationsRef for oversized, unknown-size, failed, or over-limit transfers and only decodes under-cap bundles." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "ca80412e0bb42805e26b4facab9af709580f8dd2", + "description": "WorkflowRun View CAS policy-evaluation resolution", + "committed_at": "2026-03-30T14:59:42+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 13972512 + } + ] + } +}