From d4e9abf5b16cad008ac2814bb628e8ebeebc11d3 Mon Sep 17 00:00:00 2001 From: Zoran Regvart Date: Tue, 31 Oct 2023 17:16:30 +0100 Subject: [PATCH] Support _one of_ for required tasks This adds support for the notion of _one of_ required tasks from the required tasks list. For example, given required tasks data as: ```yaml - A - [B, C, D] - E ``` Tasks "A", at least one of "B", "C" or "D", and "E" are required. --- policy/pipeline/required_tasks.rego | 43 +++++++++-- policy/pipeline/required_tasks_test.rego | 72 +++++++++++++++++++ policy/release/tasks.rego | 43 +++++++++-- policy/release/tasks_test.rego | 91 ++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 12 deletions(-) diff --git a/policy/pipeline/required_tasks.rego b/policy/pipeline/required_tasks.rego index a5985d40..e6d1b168 100644 --- a/policy/pipeline/required_tasks.rego +++ b/policy/pipeline/required_tasks.rego @@ -54,7 +54,7 @@ warn contains result if { # in the Pipeline definition. # custom: # short_name: missing_required_task -# failure_msg: Required task %q is missing +# failure_msg: '%s is missing' deny contains result if { count(tkn.tasks(input)) > 0 @@ -63,7 +63,7 @@ deny contains result if { # Don't report an error if a task is required now, but not in the future required_task in latest_required_tasks - result := lib.result_helper_with_term(rego.metadata.chain(), [required_task], required_task) + result := lib.result_helper_with_term(rego.metadata.chain(), [_format_missing(required_task, false)], required_task) } # METADATA @@ -73,7 +73,7 @@ deny contains result if { # is not currently included in the Pipeline definition. # custom: # short_name: missing_future_required_task -# failure_msg: Task %q is missing and will be required in the future +# failure_msg: '%s is missing and will be required in the future' warn contains result if { count(tkn.tasks(input)) > 0 @@ -83,7 +83,7 @@ warn contains result if { # If the required_task is also part of the current_required_tasks, do # not proceed with a warning since that's clearly a violation. not required_task in current_required_tasks - result := lib.result_helper_with_term(rego.metadata.chain(), [required_task], required_task) + result := lib.result_helper_with_term(rego.metadata.chain(), [_format_missing(required_task, true)], required_task) } # METADATA @@ -103,10 +103,31 @@ deny contains result if { # _missing_tasks returns a set of task names that are in the given # required_tasks, but not in the pipeline definition. _missing_tasks(required_tasks) := {task | - some task in required_tasks - not task in tkn.tasks_names(input) + some required_task in required_tasks + some task in _any_missing(required_task, tkn.tasks_names(input)) } +_any_missing(required, tasks) := missing if { + # one of required tasks is required + is_array(required) + + # convert arrays to sets so we can intersect below + req := lib.to_set(required) + tsk := lib.to_set(tasks) + count(req & tsk) == 0 + + # no required tasks are in tasks + missing := [required] +} else := missing if { + # above could be false, so we need to doublecheck that we're not dealing + # with an array + not is_array(required) + missing := {required | + # a required task was not found in tasks + not required in tasks + } +} else := {} + # get the future tasks that are pipeline specific. If none exists # get the default list latest_required_tasks := task_data if { @@ -122,3 +143,13 @@ current_required_tasks := task_data if { } else := task_data if { task_data := tkn.current_required_default_tasks } + +# given an array a nice message saying one of the elements of the array, +# otherwise the quoted value +_format_missing(o, opt) := desc if { + is_array(o) + desc := sprintf(`One of "%s" tasks`, [concat(`", "`, o)]) +} else := msg if { + opt + msg := sprintf("Task %q", [o]) +} else := sprintf("Required task %q", [o]) diff --git a/policy/pipeline/required_tasks_test.rego b/policy/pipeline/required_tasks_test.rego index 5d6442e9..8eeda303 100644 --- a/policy/pipeline/required_tasks_test.rego +++ b/policy/pipeline/required_tasks_test.rego @@ -229,6 +229,78 @@ test_missing_required_tasks_data if { with input as pipeline } +test_one_of_required_tasks if { + pipeline := _pipeline_with_tasks_and_label(["a", "b", "c1", "d2", "e", "f"], [], []) + data_required_tasks := {"fbc": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d2", "d3"], ["e"]}, + "effective_on": "2009-01-02T00:00:00Z", + }]} + lib.assert_empty(required_tasks.deny) with data["pipeline-required-tasks"] as data_required_tasks + with input as pipeline +} + +test_one_of_required_tasks_missing if { + pipeline := _pipeline_with_tasks_and_label(["a", "b", "d2", "e", "f"], [], []) + + data_required_tasks := {"fbc": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d3"]}, + "effective_on": "2009-01-02T00:00:00Z", + }]} + + expected := { + { + "code": "required_tasks.missing_required_task", + "msg": `One of "c1", "c2", "c3" tasks is missing`, + "term": ["c1", "c2", "c3"], + }, + { + "code": "required_tasks.missing_required_task", + "msg": `One of "d1", "d3" tasks is missing`, + "term": ["d1", "d3"], + }, + } + + lib.assert_equal_results(expected, required_tasks.deny) with data["pipeline-required-tasks"] as data_required_tasks + with input as pipeline +} + +test_future_one_of_required_tasks if { + pipeline := _pipeline_with_tasks_and_label(["a", "b", "c1", "d2", "e", "f"], [], []) + data_required_tasks := {"fbc": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d2", "d3"], ["e"]}, + "effective_on": "2099-01-02T00:00:00Z", + }]} + lib.assert_empty(required_tasks.warn) with data["pipeline-required-tasks"] as data_required_tasks + with input as pipeline +} + +test_future_one_of_required_tasks_missing if { + pipeline := _pipeline_with_tasks_and_label(["a", "b", "d2", "e", "f"], [], []) + + data_required_tasks := {"fbc": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d3"]}, + "effective_on": "2099-01-02T00:00:00Z", + }]} + + expected := { + { + "code": "required_tasks.missing_future_required_task", + "msg": `One of "c1", "c2", "c3" tasks is missing and will be required in the future`, + "term": ["c1", "c2", "c3"], + }, + { + "code": "required_tasks.missing_future_required_task", + "msg": `One of "d1", "d3" tasks is missing and will be required in the future`, + "term": ["d1", "d3"], + }, + } + lib.assert_equal_results( + expected, + required_tasks.warn, + ) with data["pipeline-required-tasks"] as data_required_tasks + with input as pipeline +} + _pipeline_with_tasks_and_label(names, finally_names, add_tasks) := pipeline if { tasks := array.concat([t | some name in names; t := _task(name)], add_tasks) finally_tasks := [t | some name in finally_names; t := _task(name)] diff --git a/policy/release/tasks.rego b/policy/release/tasks.rego index e39c38f5..c451362f 100644 --- a/policy/release/tasks.rego +++ b/policy/release/tasks.rego @@ -78,7 +78,7 @@ deny contains result if { # in the PipelineRun attestation. # custom: # short_name: required_tasks_found -# failure_msg: Required task %q is missing +# failure_msg: '%s is missing' # solution: >- # Make sure all required tasks are in the build pipeline. The required task list # is contained as xref:ec-cli:ROOT:configuration.adoc#_data_sources[data] under the key 'required-tasks'. @@ -92,7 +92,7 @@ deny contains result if { # Don't report an error if a task is required now, but not in the future required_task in latest_required_tasks - result := lib.result_helper_with_term(rego.metadata.chain(), [required_task], required_task) + result := lib.result_helper_with_term(rego.metadata.chain(), [_format_missing(required_task, false)], required_task) } # METADATA @@ -122,7 +122,7 @@ warn contains result if { # was not included in the PipelineRun attestation. # custom: # short_name: future_required_tasks_found -# failure_msg: Task %q is missing and will be required in the future +# failure_msg: '%s is missing and will be required in the future' # solution: >- # There is a task that will be required at a future date that is missing # from the build pipeline. @@ -137,7 +137,7 @@ warn contains result if { # If the required_task is also part of the current_required_tasks, do # not proceed with a warning since that's clearly a violation. not required_task in current_required_tasks - result := lib.result_helper_with_term(rego.metadata.chain(), [required_task], required_task) + result := lib.result_helper_with_term(rego.metadata.chain(), [_format_missing(required_task, true)], required_task) } # METADATA @@ -169,10 +169,31 @@ _missing_tasks(required_tasks) := {task | some att in lib.pipelinerun_attestations count(tkn.tasks(att)) > 0 - some task in required_tasks - not task in tkn.tasks_names(att) + some required_task in required_tasks + some task in _any_missing(required_task, tkn.tasks_names(att)) } +_any_missing(required, tasks) := missing if { + # one of required tasks is required + is_array(required) + + # convert arrays to sets so we can intersect below + req := lib.to_set(required) + tsk := lib.to_set(tasks) + count(req & tsk) == 0 + + # no required tasks are in tasks + missing := [required] +} else := missing if { + # above could be false, so we need to doublecheck that we're not dealing + # with an array + not is_array(required) + missing := {required | + # a required task was not found in tasks + not required in tasks + } +} else := {} + # get the future tasks that are pipeline specific. If none exists # get the default list latest_required_tasks := task_data if { @@ -229,3 +250,13 @@ _slsav1_status(condition) := status if { condition.status == "False" status := "Failed" } + +# given an array a nice message saying one of the elements of the array, +# otherwise the quoted value +_format_missing(o, opt) := desc if { + is_array(o) + desc := sprintf(`One of "%s" tasks`, [concat(`", "`, o)]) +} else := msg if { + opt + msg := sprintf("Task %q", [o]) +} else := sprintf("Required task %q", [o]) diff --git a/policy/release/tasks_test.rego b/policy/release/tasks_test.rego index d77e87df..2b3ed98e 100644 --- a/policy/release/tasks_test.rego +++ b/policy/release/tasks_test.rego @@ -332,6 +332,97 @@ test_invalid_status_conditions if { lib.assert_equal(["MISSING"], tasks._status(given_task)) } +test_one_of_required_tasks if { + attestation_v02 := _attestations_with_tasks(["a", "b", "c1", "d2", "e", "f"], []) + data_required_tasks := {"generic": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d2", "d3"], ["e"]}, + "effective_on": "2009-01-02T00:00:00Z", + }]} + lib.assert_empty(tasks.deny) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v02 + + attestation_v1 := _slsav1_attestations_with_tasks(["a", "b", "c1", "d2", "e", "f"], []) + lib.assert_empty(tasks.deny) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v1 +} + +test_one_of_required_tasks_missing if { + attestation_v02 := _attestations_with_tasks(["a", "b", "d2", "e", "f"], []) + + data_required_tasks := {"generic": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d3"]}, + "effective_on": "2009-01-02T00:00:00Z", + }]} + + expected := { + { + "code": "tasks.required_tasks_found", + "msg": `One of "c1", "c2", "c3" tasks is missing`, + "term": ["c1", "c2", "c3"], + }, + { + "code": "tasks.required_tasks_found", + "msg": `One of "d1", "d3" tasks is missing`, + "term": ["d1", "d3"], + }, + } + + lib.assert_equal_results(expected, tasks.deny) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v02 + + attestation_v1 := _slsav1_attestations_with_tasks(["a", "b", "d2", "e", "f"], []) + lib.assert_equal_results(expected, tasks.deny) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v1 +} + +test_future_one_of_required_tasks if { + attestation_v02 := _attestations_with_tasks(["a", "b", "c1", "d2", "e", "f"], []) + data_required_tasks := {"generic": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d2", "d3"], ["e"]}, + "effective_on": "2099-01-02T00:00:00Z", + }]} + lib.assert_empty(tasks.warn) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v02 + + attestation_v1 := _slsav1_attestations_with_tasks(["a", "b", "c1", "d2", "e", "f"], []) + lib.assert_empty(tasks.warn) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v1 +} + +test_future_one_of_required_tasks_missing if { + attestation_v02 := _attestations_with_tasks(["a", "b", "d2", "e", "f"], []) + + data_required_tasks := {"generic": [{ + "tasks": {"a", ["c1", "c2", "c3"], ["d1", "d3"]}, + "effective_on": "2099-01-02T00:00:00Z", + }]} + + expected := { + { + "code": "tasks.future_required_tasks_found", + "msg": `One of "c1", "c2", "c3" tasks is missing and will be required in the future`, + "term": ["c1", "c2", "c3"], + }, + { + "code": "tasks.future_required_tasks_found", + "msg": `One of "d1", "d3" tasks is missing and will be required in the future`, + "term": ["d1", "d3"], + }, + } + lib.assert_equal_results( + expected, + tasks.warn, + ) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v02 + + attestation_v1 := _slsav1_attestations_with_tasks(["a", "b", "d2", "e", "f"], []) + lib.assert_equal_results( + expected, + tasks.warn, + ) with data["pipeline-required-tasks"] as data_required_tasks + with input.attestations as attestation_v1 +} + _attestations_with_tasks(names, add_tasks) := attestations if { tasks := array.concat([t | some name in names; t := _task(name)], add_tasks)