From c116a145b605596e79354a85e9b0f4f2c3046bac Mon Sep 17 00:00:00 2001 From: arewm Date: Fri, 27 Feb 2026 16:28:39 -0500 Subject: [PATCH] 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 | 111 +++++++++++++- policy/lib/tekton/trusted_test.rego | 140 ++++++++++++++++++ .../trusted_task/trusted_task_test.rego | 32 ++++ 3 files changed, 280 insertions(+), 3 deletions(-) diff --git a/policy/lib/tekton/trusted.rego b/policy/lib/tekton/trusted.rego index da71ef1a6..4a1633112 100644 --- a/policy/lib/tekton/trusted.rego +++ b/policy/lib/tekton/trusted.rego @@ -125,14 +125,18 @@ untrusted_task_refs_rules(tasks, bundle_manifests) := {task | } # Returns true if the task uses a trusted Task reference according to trusted_task_rules. -# 1. If task matches a deny rule, it's not trusted -# 2. If task matches an allow rule, it's trusted -# 3. Otherwise, it's not trusted +# To be trusted, a task should: +# 1. Match at least one allow rule +# 2. Not match any deny rule +# 3. If a matched allow rule specifies signature details, the +# task bundle must successfully pass a signature check +# Otherwise, it's not trusted. # bundle_manifests is a map of bundle_ref -> manifest from ec.oci.image_manifests is_trusted_task_rules(task, bundle_manifests) if { ref := task_ref(task) not _task_matches_deny_rule(ref, bundle_manifests) _task_matches_allow_rule(ref, bundle_manifests) + _task_bundle_sig_check_okay(ref, bundle_manifests) } # Merging in the trusted_task_rules rule data @@ -399,6 +403,33 @@ denial_reason(task, bundle_manifests) := reason if { "pattern": deny_info.patterns, "messages": deny_info.messages, } +} else := reason if { + # Case: Matches allow rule but fails signature verification + ref := task_ref(task) + not _task_matches_deny_rule(ref, bundle_manifests) + _task_matches_allow_rule(ref, bundle_manifests) + not _task_bundle_sig_check_okay(ref, bundle_manifests) + + sig_rules := [rule | + some rule in _effective_allow_rules + _pattern_matches(ref.key, rule.pattern) + _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) + rule.signing_identity + ] + messages := [msg | + some rule in sig_rules + opts := _sigstore_opts_for_rule(rule) + msg := sprintf( + "Task bundle %s failed signature verification for pattern %s with options %v", + [ref.bundle, rule.pattern, opts], + ) + ] + + reason := { + "type": "signing_identity_failed", + "pattern": [rule.pattern | some rule in sig_rules], + "messages": messages, + } } else := reason if { # Case 2: Doesn't match any allow rule # Only applies if there are effective allow rules defined @@ -449,6 +480,59 @@ _task_matches_allow_rule(ref, bundle_manifests) if { _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) } +# Returns true if the task's signature is verified against all matching allow rules +# that require signature verification. True when: +# - No matching allow rule has a signing_identity config, OR +# - The ref is not an OCI bundle (git tasks are exempt), OR +# - At least one matching allow rule's signature verification passes +_task_bundle_sig_check_okay(ref, bundle_manifests) if { + matching_rules := [rule | + some rule in _effective_allow_rules + _pattern_matches(ref.key, rule.pattern) + _version_satisfies_all_rule_constraints(ref, rule, bundle_manifests) + ] + _signature_verified_for_rules(ref, matching_rules) +} + +_signature_verified_for_rules(_, matching_rules) if { + some rule in matching_rules + not rule.signing_identity +} + +_signature_verified_for_rules(ref, _) if { + not ref.bundle +} + +_signature_verified_for_rules(ref, matching_rules) if { + ref.bundle + some rule in matching_rules + rule.signing_identity + opts := _sigstore_opts_for_rule(rule) + not _sigstore_verify_has_errors(ref.bundle, opts) +} + +# Omit empty/false values so Sigstore doesn't treat them as "no constraint". +_sigstore_opts_for_rule(rule) := opts if { + sv := rule.signing_identity + is_object(sv) + opts := {k: v | + some k, v in sv + v != "" + v != false + } +} + +# Todo: This gets "called" first to check if the task is trusted and +# then again when generating a denial reason text. Generally OPA memoizes +# everything so it would not be re-run when called the second time, but +# I'm not sure if the call to ec.sigstore.verify_image makes OPA think it +# can't memoize it. If we are really calling the (possibly expensive) +# sigstore verify twice then we might want to refactor and try to avoid that. +_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. # Wildcards (*) match any sequence of characters. Patterns without a # wildcard also match keys that have a :tag suffix appended (e.g. @@ -488,6 +572,27 @@ _trusted_task_rule_entry_schema := { "description": "List of version constraints", "items": {"type": "string"}, }, + "signing_identity": { + "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", "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", "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 6b3635fb1..b078b9085 100644 --- a/policy/lib/tekton/trusted_test.rego +++ b/policy/lib/tekton/trusted_test.rego @@ -1188,6 +1188,146 @@ untrusted_git_task := { ]}}, } +# ============================================================================= +# SIGNATURE VERIFICATION TESTS +# ============================================================================= + +# Test: Allow rule without signing_identity works as before (backward compat) +test_allow_rule_without_signing_identity 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 signing_identity passes when signature is valid +test_allow_rule_with_valid_signature if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signing_identity": { + "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 data.rule_data.trusted_task_rules_enabled as true + with ec.sigstore.verify_image as _mock_verify_image_success +} + +# Test: Allow rule with signing_identity fails when signature is invalid +test_allow_rule_with_invalid_signature if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signing_identity": { + "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 data.rule_data.trusted_task_rules_enabled as true + 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*", + "signing_identity": { + "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 data.rule_data.trusted_task_rules_enabled as true + with ec.sigstore.verify_image as _mock_verify_image_failure +} + +# Test: Git-resolved tasks are exempt from signature verification +test_git_tasks_exempt_from_signing_identity if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "git\\+git\\.local/repo\\.git//*", + "signing_identity": { + "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 data.rule_data.trusted_task_rules_enabled as true + with ec.sigstore.verify_image as _mock_verify_image_failure +} + +# Test: denial_reason returns signing_identity_failed when pattern matches but sig fails +test_denial_reason_signing_identity_failed if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signing_identity": { + "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 data.rule_data.trusted_task_rules_enabled as true + with ec.sigstore.verify_image as _mock_verify_image_failure + + assertions.assert_equal("signing_identity_failed", reason.type) + assertions.assert_equal(["oci://registry.local/trusty*"], reason.pattern) + count(reason.messages) == 1 + contains(reason.messages[0], "failed signature verification") + contains(reason.messages[0], "oci://registry.local/trusty*") + contains(reason.messages[0], "certificate_identity_regexp") +} + +# Test: Schema validation accepts valid signing_identity on allow rules +test_schema_accepts_signing_identity if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signing_identity": { + "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 signing_identity + assertions.assert_empty(tekton.data_errors) with data.rule_data.trusted_task_rules as rules +} + +# Test: Schema validation rejects empty signing_identity (would be silently permissive) +test_schema_rejects_empty_signing_identity if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signing_identity": {}, + }]}} + + count(tekton.data_errors) > 0 with data.rule_data.trusted_task_rules as rules +} + +# Test: Schema validation rejects signing_identity with only non-identity fields +test_schema_rejects_signing_identity_without_identity if { + rules := {"allow": {"signed-catalog": [{ + "pattern": "oci://registry.local/trusty*", + "signing_identity": {"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": []} + +_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_test.rego b/policy/release/trusted_task/trusted_task_test.rego index f633b5f0d..12bd32daa 100644 --- a/policy/release/trusted_task/trusted_task_test.rego +++ b/policy/release/trusted_task/trusted_task_test.rego @@ -1358,6 +1358,38 @@ test_mixed_trusted_and_untrusted_tasks if { with ec.oci.image_manifest as _mock_image_manifest } +test_signing_identity_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*", + "signing_identity": { + "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 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 + + count(results) > 0 + some result in results + contains(result.msg, "signing_identity_failed") +} + +_mock_verify_image_failure(_, _) := {"success": false, "errors": ["signature verification failed"]} + +_mock_empty_manifests(_) := {} + ##################################################### # Helper Functions for trusted_task_rules tests #####################################################