From 4e630ec6c156d4af47fdb5457b8dd764317705da Mon Sep 17 00:00:00 2001 From: arewm Date: Fri, 27 Feb 2026 16:28:39 -0500 Subject: [PATCH 1/4] trusted_task_rules: Add per-allow-rule signature verification Add optional `signature_verification` configuration to allow rules in trusted_task_rules, enabling sigstore-based signature verification as an additional trust dimension for task bundles. When an allow rule includes `signature_verification`, matching bundles must also pass sigstore verification with the configured identity/key. Rules without the field continue to work as before (pattern-only trust). Git-resolved tasks are exempt since ec.sigstore.verify_image only works on OCI refs. A new denial reason type `signature_verification_failed` is surfaced when a task matches an allow rule's pattern/version constraints but fails signature verification. Ref: EC-1545 Assisted-by: Claude Code (Opus 4.6) --- policy/lib/tekton/trusted.rego | 71 +++++++++++ policy/lib/tekton/trusted_test.rego | 112 ++++++++++++++++++ policy/release/trusted_task/trusted_task.rego | 5 + .../trusted_task/trusted_task_test.rego | 31 +++++ 4 files changed, 219 insertions(+) diff --git a/policy/lib/tekton/trusted.rego b/policy/lib/tekton/trusted.rego index 6756d28f6..7905d25e6 100644 --- a/policy/lib/tekton/trusted.rego +++ b/policy/lib/tekton/trusted.rego @@ -399,6 +399,23 @@ denial_reason(task, bundle_manifests) := reason if { "pattern": deny_info.patterns, "messages": deny_info.messages, } +} else := reason if { + # Case: Matches pattern+version on an allow rule but fails signature verification + ref := task_ref(task) + not _task_matches_deny_rule(ref, bundle_manifests) + + some rule in _effective_allow_rules + _pattern_matches(ref.key, rule.pattern) + _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) + + # But no allow rule passes signature verification + not _task_matches_allow_rule(ref, bundle_manifests) + + reason := { + "type": "signature_verification_failed", + "pattern": [], + "messages": [sprintf("Task bundle %s failed signature verification", [ref.bundle])], + } } else := reason if { # Case 2: Doesn't match any allow rule # Only applies if there are effective allow rules defined @@ -447,6 +464,45 @@ _task_matches_allow_rule(ref, bundle_manifests) if { some rule in _effective_allow_rules _pattern_matches(ref.key, rule.pattern) _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) + _signature_verified_for_rule(ref, rule) +} + +# Build sigstore opts object from a rule's signature_verification config. +_sigstore_opts_for_rule(rule) := opts if { + sv := rule.signature_verification + is_object(sv) + opts := { + "certificate_identity": object.get(sv, "certificate_identity", ""), + "certificate_identity_regexp": object.get(sv, "certificate_identity_regexp", ""), + "certificate_oidc_issuer": object.get(sv, "certificate_oidc_issuer", ""), + "certificate_oidc_issuer_regexp": object.get(sv, "certificate_oidc_issuer_regexp", ""), + "ignore_rekor": object.get(sv, "ignore_rekor", false), + "public_key": object.get(sv, "public_key", ""), + "rekor_url": object.get(sv, "rekor_url", ""), + } +} + +# A ref is signature-verified for a given rule if: +# 1. The rule has no signature_verification config (pass through), OR +# 2. The ref is not an OCI bundle (git tasks are exempt), OR +# 3. The bundle passes sigstore verification with the rule's opts +_signature_verified_for_rule(_, rule) if { + not rule.signature_verification +} + +_signature_verified_for_rule(ref, _) if { + not ref.bundle +} + +_signature_verified_for_rule(ref, rule) if { + ref.bundle + opts := _sigstore_opts_for_rule(rule) + not _sigstore_verify_has_errors(ref.bundle, opts) +} + +_sigstore_verify_has_errors(bundle, opts) if { + info := ec.sigstore.verify_image(bundle, opts) + some _ in info.errors } # Checks if the key matches the wildcard pattern using glob matching. @@ -488,6 +544,21 @@ _trusted_task_rule_entry_schema := { "description": "List of version constraints", "items": {"type": "string"}, }, + "signature_verification": { + "type": "object", + # regal ignore:line-length + "description": "Sigstore verification options. When present, bundles matching this allow rule must also have a verified signature.", + "properties": { + "certificate_identity": {"type": "string"}, + "certificate_identity_regexp": {"type": "string"}, + "certificate_oidc_issuer": {"type": "string"}, + "certificate_oidc_issuer_regexp": {"type": "string"}, + "ignore_rekor": {"type": "boolean"}, + "public_key": {"type": "string"}, + "rekor_url": {"type": "string"}, + }, + "additionalProperties": false, + }, }, "additionalProperties": true, } diff --git a/policy/lib/tekton/trusted_test.rego b/policy/lib/tekton/trusted_test.rego index 6168120a4..bab379f6b 100644 --- a/policy/lib/tekton/trusted_test.rego +++ b/policy/lib/tekton/trusted_test.rego @@ -1188,6 +1188,118 @@ untrusted_git_task := { ]}}, } +# ============================================================================= +# SIGNATURE VERIFICATION TESTS +# ============================================================================= + +# Test: Allow rule without signature_verification works as before (backward compat) +test_allow_rule_without_signature_verification if { + rules := {"allow": {"test-group": [{"pattern": "oci://registry.local/trusty*"}]}} + + tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules +} + +# Test: Allow rule with signature_verification passes when signature is valid +test_allow_rule_with_valid_signature if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + }, + }]}} + + tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with ec.sigstore.verify_image as _mock_verify_image_success +} + +# Test: Allow rule with signature_verification fails when signature is invalid +test_allow_rule_with_invalid_signature if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + }, + }]}} + + not tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with ec.sigstore.verify_image as _mock_verify_image_failure +} + +# Test: Multiple allow rules - task passes if any rule's pattern+signature match +test_multiple_allow_rules_different_sig_configs if { + rules := {"allow": { + "signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + }, + }], + "unsigned-catalog": [{"pattern": "oci://registry.local/trusty*"}], + }} + + # Passes because the unsigned-catalog rule has no sig verification requirement + tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with ec.sigstore.verify_image as _mock_verify_image_failure +} + +# Test: Git-resolved tasks are exempt from signature verification +test_git_tasks_exempt_from_signature_verification if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "git\\+git\\.local/repo\\.git//*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + }, + }]}} + + # Git task should pass even though ec.sigstore.verify_image would fail, + # because git tasks are exempt from signature verification + tekton.is_trusted_task(trusted_git_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with ec.sigstore.verify_image as _mock_verify_image_failure +} + +# Test: denial_reason returns signature_verification_failed when pattern matches but sig fails +test_denial_reason_signature_verification_failed if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + }, + }]}} + + reason := tekton.denial_reason(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with ec.sigstore.verify_image as _mock_verify_image_failure + + assertions.assert_equal("signature_verification_failed", reason.type) + assertions.assert_equal([], reason.pattern) + count(reason.messages) == 1 + contains(reason.messages[0], "failed signature verification") +} + +# Test: Schema validation accepts valid signature_verification on allow rules +test_schema_accepts_signature_verification if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + "ignore_rekor": true, + }, + }]}} + + # No data errors should be produced for valid signature_verification + assertions.assert_empty(tekton.data_errors) with data.rule_data.trusted_task_rules as rules +} + +# Mock helpers for signature verification tests +_mock_verify_image_success(_, _) := {"success": true, "errors": []} + +_mock_verify_image_failure(_, _) := {"success": false, "errors": ["signature verification failed"]} + # ============================================================================= # BEGIN LEGACY TEST DATA (trusted_tasks) # DELETE THIS SECTION when removing legacy support. diff --git a/policy/release/trusted_task/trusted_task.rego b/policy/release/trusted_task/trusted_task.rego index 9b79ddf9e..4ed35cdda 100644 --- a/policy/release/trusted_task/trusted_task.rego +++ b/policy/release/trusted_task/trusted_task.rego @@ -455,6 +455,11 @@ _format_denial_reason(reason) := msg if { pattern_lines := [sprintf(" - %s", [pattern]) | some pattern in reason.pattern] msg := sprintf("%s\n%s", [reason.type, concat("\n", pattern_lines)]) +} else := msg if { + reason.type == "signature_verification_failed" + count(reason.messages) > 0 + message_lines := [sprintf(" - %s", [m]) | some m in reason.messages] + msg := sprintf("%s\n%s", [reason.type, concat("\n", message_lines)]) } else := reason.type # Format error for rules system with Trusted Artifacts diff --git a/policy/release/trusted_task/trusted_task_test.rego b/policy/release/trusted_task/trusted_task_test.rego index f633b5f0d..4a2acdf23 100644 --- a/policy/release/trusted_task/trusted_task_test.rego +++ b/policy/release/trusted_task/trusted_task_test.rego @@ -1358,6 +1358,37 @@ test_mixed_trusted_and_untrusted_tasks if { with ec.oci.image_manifest as _mock_image_manifest } +test_signature_verification_failed_error_rules if { + att := {"statement": { + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [trusted_bundle_pipeline_task]}, + }, + }} + + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": { + "certificate_identity_regexp": "https://tekton.dev/chains/.*", + "certificate_oidc_issuer": "https://accounts.google.com", + }, + }]}} + + results := trusted_task.deny with input.attestations as [att] + with data.trusted_task_rules as rules + with ec.sigstore.verify_image as _mock_verify_image_failure + with ec.oci.image_manifests as _mock_empty_manifests + + count(results) > 0 + some result in results + contains(result.msg, "signature_verification_failed") +} + +_mock_verify_image_failure(_, _) := {"success": false, "errors": ["signature verification failed"]} + +_mock_empty_manifests(_) := {} + ##################################################### # Helper Functions for trusted_task_rules tests ##################################################### From d35d3843405e3e9e1a2b61f4b8a96d69363a998e Mon Sep 17 00:00:00 2001 From: arewm Date: Sat, 9 May 2026 08:12:44 -0400 Subject: [PATCH 2/4] trusted_task_rules: Extract pattern+version matching helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicate the pattern+version matching logic shared between _task_matches_allow_rule and the signature_verification_failed denial_reason branch into _task_matches_allow_rule_pattern_version. No behavior change — pure refactor. Assisted-by: Claude Code (Opus 4.6) --- policy/lib/tekton/trusted.rego | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/policy/lib/tekton/trusted.rego b/policy/lib/tekton/trusted.rego index 7905d25e6..90238df52 100644 --- a/policy/lib/tekton/trusted.rego +++ b/policy/lib/tekton/trusted.rego @@ -405,8 +405,7 @@ denial_reason(task, bundle_manifests) := reason if { not _task_matches_deny_rule(ref, bundle_manifests) some rule in _effective_allow_rules - _pattern_matches(ref.key, rule.pattern) - _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) + _task_matches_allow_rule_pattern_version(ref, rule, bundle_manifests) # But no allow rule passes signature verification not _task_matches_allow_rule(ref, bundle_manifests) @@ -462,9 +461,13 @@ _denying_rules_info(task, bundle_manifests) := {"patterns": patterns, "messages" # bundle_manifests is a map of bundle_ref -> manifest from ec.oci.image_manifests _task_matches_allow_rule(ref, bundle_manifests) if { some rule in _effective_allow_rules + _task_matches_allow_rule_pattern_version(ref, rule, bundle_manifests) + _signature_verified_for_rule(ref, rule) +} + +_task_matches_allow_rule_pattern_version(ref, rule, bundle_manifests) if { _pattern_matches(ref.key, rule.pattern) _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) - _signature_verified_for_rule(ref, rule) } # Build sigstore opts object from a rule's signature_verification config. From 833351167a7c33b8e595c459095010cfad91a11b Mon Sep 17 00:00:00 2001 From: arewm Date: Sat, 9 May 2026 08:13:39 -0400 Subject: [PATCH 3/4] trusted_task_rules: Reject empty signature_verification config An empty `signature_verification: {}` block would silently become permissive because empty-string defaults are treated as "no constraint" by Sigstore. Fix this in two ways: - _sigstore_opts_for_rule now passes through only explicitly-set non-default values instead of emitting empty-string defaults - Schema requires at least one of certificate_identity, certificate_identity_regexp, or public_key via anyOf, and enforces minLength: 1 on all string fields Assisted-by: Claude Code (Opus 4.6) --- policy/lib/tekton/trusted.rego | 32 ++++++++++++++++------------- policy/lib/tekton/trusted_test.rego | 20 ++++++++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/policy/lib/tekton/trusted.rego b/policy/lib/tekton/trusted.rego index 90238df52..d10a64ad3 100644 --- a/policy/lib/tekton/trusted.rego +++ b/policy/lib/tekton/trusted.rego @@ -471,17 +471,15 @@ _task_matches_allow_rule_pattern_version(ref, rule, bundle_manifests) if { } # Build sigstore opts object from a rule's signature_verification config. +# Only includes keys that are explicitly set to non-default values to avoid +# empty strings being interpreted as "no constraint" by Sigstore. _sigstore_opts_for_rule(rule) := opts if { sv := rule.signature_verification is_object(sv) - opts := { - "certificate_identity": object.get(sv, "certificate_identity", ""), - "certificate_identity_regexp": object.get(sv, "certificate_identity_regexp", ""), - "certificate_oidc_issuer": object.get(sv, "certificate_oidc_issuer", ""), - "certificate_oidc_issuer_regexp": object.get(sv, "certificate_oidc_issuer_regexp", ""), - "ignore_rekor": object.get(sv, "ignore_rekor", false), - "public_key": object.get(sv, "public_key", ""), - "rekor_url": object.get(sv, "rekor_url", ""), + opts := {k: v | + some k, v in sv + v != "" + v != false } } @@ -552,15 +550,21 @@ _trusted_task_rule_entry_schema := { # regal ignore:line-length "description": "Sigstore verification options. When present, bundles matching this allow rule must also have a verified signature.", "properties": { - "certificate_identity": {"type": "string"}, - "certificate_identity_regexp": {"type": "string"}, - "certificate_oidc_issuer": {"type": "string"}, - "certificate_oidc_issuer_regexp": {"type": "string"}, + "certificate_identity": {"type": "string", "minLength": 1}, + "certificate_identity_regexp": {"type": "string", "minLength": 1}, + "certificate_oidc_issuer": {"type": "string", "minLength": 1}, + "certificate_oidc_issuer_regexp": {"type": "string", "minLength": 1}, "ignore_rekor": {"type": "boolean"}, - "public_key": {"type": "string"}, - "rekor_url": {"type": "string"}, + "public_key": {"type": "string", "minLength": 1}, + "rekor_url": {"type": "string", "minLength": 1}, }, "additionalProperties": false, + # regal ignore:line-length + "anyOf": [ + {"required": ["certificate_identity"]}, + {"required": ["certificate_identity_regexp"]}, + {"required": ["public_key"]}, + ], }, }, "additionalProperties": true, diff --git a/policy/lib/tekton/trusted_test.rego b/policy/lib/tekton/trusted_test.rego index bab379f6b..c64fe5c18 100644 --- a/policy/lib/tekton/trusted_test.rego +++ b/policy/lib/tekton/trusted_test.rego @@ -1295,6 +1295,26 @@ test_schema_accepts_signature_verification if { assertions.assert_empty(tekton.data_errors) with data.rule_data.trusted_task_rules as rules } +# Test: Schema validation rejects empty signature_verification (would be silently permissive) +test_schema_rejects_empty_signature_verification if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": {}, + }]}} + + count(tekton.data_errors) > 0 with data.rule_data.trusted_task_rules as rules +} + +# Test: Schema validation rejects signature_verification with only non-identity fields +test_schema_rejects_signature_verification_without_identity if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signature_verification": {"ignore_rekor": true}, + }]}} + + count(tekton.data_errors) > 0 with data.rule_data.trusted_task_rules as rules +} + # Mock helpers for signature verification tests _mock_verify_image_success(_, _) := {"success": true, "errors": []} From 02213182e42cf06ef2f31af52a3df7573de94106 Mon Sep 17 00:00:00 2001 From: Simon Baird Date: Tue, 23 Jun 2026 11:57:22 -0400 Subject: [PATCH 4/4] trusted_task_rules: Fix tests after rebase --- policy/lib/tekton/trusted_test.rego | 6 ++++++ policy/release/trusted_task/trusted_task_test.rego | 1 + 2 files changed, 7 insertions(+) diff --git a/policy/lib/tekton/trusted_test.rego b/policy/lib/tekton/trusted_test.rego index c64fe5c18..013317806 100644 --- a/policy/lib/tekton/trusted_test.rego +++ b/policy/lib/tekton/trusted_test.rego @@ -1197,6 +1197,7 @@ test_allow_rule_without_signature_verification if { rules := {"allow": {"test-group": [{"pattern": "oci://registry.local/trusty*"}]}} tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true } # Test: Allow rule with signature_verification passes when signature is valid @@ -1210,6 +1211,7 @@ test_allow_rule_with_valid_signature if { }]}} tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true with ec.sigstore.verify_image as _mock_verify_image_success } @@ -1224,6 +1226,7 @@ test_allow_rule_with_invalid_signature if { }]}} not tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true with ec.sigstore.verify_image as _mock_verify_image_failure } @@ -1242,6 +1245,7 @@ test_multiple_allow_rules_different_sig_configs if { # Passes because the unsigned-catalog rule has no sig verification requirement tekton.is_trusted_task(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true with ec.sigstore.verify_image as _mock_verify_image_failure } @@ -1258,6 +1262,7 @@ test_git_tasks_exempt_from_signature_verification if { # Git task should pass even though ec.sigstore.verify_image would fail, # because git tasks are exempt from signature verification tekton.is_trusted_task(trusted_git_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true with ec.sigstore.verify_image as _mock_verify_image_failure } @@ -1272,6 +1277,7 @@ test_denial_reason_signature_verification_failed if { }]}} reason := tekton.denial_reason(trusted_bundle_task, _empty_bundle_manifests) with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true with ec.sigstore.verify_image as _mock_verify_image_failure assertions.assert_equal("signature_verification_failed", reason.type) diff --git a/policy/release/trusted_task/trusted_task_test.rego b/policy/release/trusted_task/trusted_task_test.rego index 4a2acdf23..71cfa260c 100644 --- a/policy/release/trusted_task/trusted_task_test.rego +++ b/policy/release/trusted_task/trusted_task_test.rego @@ -1377,6 +1377,7 @@ test_signature_verification_failed_error_rules if { results := trusted_task.deny with input.attestations as [att] with data.trusted_task_rules as rules + with data.rule_data.trusted_task_rules_enabled as true with ec.sigstore.verify_image as _mock_verify_image_failure with ec.oci.image_manifests as _mock_empty_manifests