diff --git a/policy/lib/tekton/pipeline.rego b/policy/lib/tekton/pipeline.rego index 8510f5e5..06d8b29f 100644 --- a/policy/lib/tekton/pipeline.rego +++ b/policy/lib/tekton/pipeline.rego @@ -4,6 +4,7 @@ import future.keywords.contains import future.keywords.if import future.keywords.in +import data.lib import data.lib.time as ectime pipeline_label := "pipelines.openshift.io/runtime" @@ -33,12 +34,16 @@ pipeline_label_selector(pipeline) := value if { not is_fbc # given that the build task is shared between fbc and docker builds we can't rely on the task's label # Labels of the build Task from the SLSA Provenance v1.0 of a PipelineRun - value := build_task(pipeline).metadata.labels[task_label] + values := [l | some build_task in build_tasks(pipeline); l := build_task.metadata.labels[task_label]] + count(lib.to_set(values)) == 1 + value := values[0] } else := value if { not is_fbc # given that the build task is shared between fbc and docker builds we can't rely on the task's label # Labels of the build Task from the SLSA Provenance v0.2 of a PipelineRun - value := build_task(pipeline).invocation.environment.labels[task_label] + values := [l | some build_task in build_tasks(pipeline); l := build_task.invocation.environment.labels[task_label]] + count(lib.to_set(values)) == 1 + value := values[0] } else := value if { # PipelineRun labels found in the SLSA Provenance v1.0 value := pipeline.statement.predicate.buildDefinition.internalParameters.labels[pipeline_label] diff --git a/policy/lib/tekton/task.rego b/policy/lib/tekton/task.rego index 63602880..4b7da2ac 100644 --- a/policy/lib/tekton/task.rego +++ b/policy/lib/tekton/task.rego @@ -152,7 +152,7 @@ task_step_image_ref(step) := step.environment.image task_step_image_ref(step) := step.imageID # build_task returns the build task found in the attestation -build_task(attestation) := task if { +build_tasks(attestation) := [task | some task in tasks(attestation) image_url := task_result(task, "IMAGE_URL") @@ -160,9 +160,9 @@ build_task(attestation) := task if { image_digest := task_result(task, "IMAGE_DIGEST") count(trim_space(image_digest)) > 0 -} +] -git_clone_task(attestation) := task if { +git_clone_tasks(attestation) := [task | some task in tasks(attestation) commit := task_result(task, "commit") @@ -170,7 +170,7 @@ git_clone_task(attestation) := task if { url := task_result(task, "url") count(trim_space(url)) > 0 -} +] # task_data returns the data relating to the task. If the task is # referenced from a bundle, the "bundle" attribute is included. diff --git a/policy/lib/tekton/task_test.rego b/policy/lib/tekton/task_test.rego index bcb3e43f..dd4cf7ad 100644 --- a/policy/lib/tekton/task_test.rego +++ b/policy/lib/tekton/task_test.rego @@ -303,7 +303,7 @@ test_tasks_from_pipeline_with_spam if { test_build_task if { expected := _good_build_task - lib.assert_equal(expected, tkn.build_task(_good_attestation)) + lib.assert_equal([expected], tkn.build_tasks(_good_attestation)) } test_build_task_not_found if { @@ -312,22 +312,56 @@ test_build_task_not_found if { "path": "/statement/predicate/buildConfig/tasks/0/results/0/name", "value": "IMAGE_URL_SKIP", }]) - not tkn.build_task(missing_image_url) + count(tkn.build_tasks(missing_image_url)) == 0 missing_image_digest := json.patch(_good_attestation, [{ "op": "add", "path": "/statement/predicate/buildConfig/tasks/0/results/1/name", "value": "IMAGE_DIGEST_SKIP", }]) - not tkn.build_task(missing_image_digest) + count(tkn.build_tasks(missing_image_digest)) == 0 missing_results := json.remove(_good_attestation, ["/statement/predicate/buildConfig/tasks/0/results"]) - not tkn.build_task(missing_results) + count(tkn.build_tasks(missing_results)) == 0 +} + +test_multiple_build_tasks if { + task1 := json.patch(_good_build_task, [{ + "op": "replace", + "path": "/ref/name", + "value": "buildah-1", + }]) + + task2 := json.patch(_good_build_task, [{ + "op": "replace", + "path": "/ref/name", + "value": "buildah-2", + }]) + + task3 := json.patch(_good_build_task, [{ + "op": "replace", + "path": "/ref/name", + "value": "buildah-3", + }]) + + attestation3 := {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, task2, task3]}, + }}} + + count(tkn.build_tasks(attestation3)) == 3 + + attestation2 := {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, _good_git_clone_task, task3]}, + }}} + + count(tkn.build_tasks(attestation2)) == 2 } test_git_clone_task if { expected := _good_git_clone_task - lib.assert_equal(expected, tkn.git_clone_task(_good_attestation)) + lib.assert_equal([expected], tkn.git_clone_tasks(_good_attestation)) } test_git_clone_task_not_found if { @@ -336,17 +370,51 @@ test_git_clone_task_not_found if { "path": "/statement/predicate/buildConfig/tasks/1/results/0/name", "value": "you-argh-el", }]) - not tkn.git_clone_task(missing_url) + count(tkn.git_clone_tasks(missing_url)) == 0 missing_commit := json.patch(_good_attestation, [{ "op": "add", "path": "/statement/predicate/buildConfig/tasks/1/results/1/name", "value": "bachelor", }]) - not tkn.git_clone_task(missing_commit) + count(tkn.git_clone_tasks(missing_commit)) == 0 missing_results := json.remove(_good_attestation, ["/statement/predicate/buildConfig/tasks/1/results"]) - not tkn.git_clone_task(missing_results) + count(tkn.git_clone_tasks(missing_results)) == 0 +} + +test_multiple_git_clone_tasks if { + task1 := json.patch(_good_git_clone_task, [{ + "op": "replace", + "path": "/ref/name", + "value": "git-clone-1", + }]) + + task2 := json.patch(_good_git_clone_task, [{ + "op": "replace", + "path": "/ref/name", + "value": "git-clone-2", + }]) + + task3 := json.patch(_good_git_clone_task, [{ + "op": "replace", + "path": "/ref/name", + "value": "git-clone-3", + }]) + + attestation3 := {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, task2, task3]}, + }}} + + count(tkn.git_clone_tasks(attestation3)) == 3 + + attestation2 := {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, _good_build_task, task3]}, + }}} + + count(tkn.git_clone_tasks(attestation2)) == 2 } test_task_data_bundle_ref if { diff --git a/policy/release/hermetic_build_task.rego b/policy/release/hermetic_build_task.rego index 8b2a2fe5..28e77dfb 100644 --- a/policy/release/hermetic_build_task.rego +++ b/policy/release/hermetic_build_task.rego @@ -31,14 +31,12 @@ import data.lib.tkn # - attestation_type.known_attestation_type # deny contains result if { - hermetic_build != "true" + _hermetic_build != {"true"} result := lib.result_helper(rego.metadata.chain(), []) } -default hermetic_build := "false" - -hermetic_build := value if { +_hermetic_build contains value if { some attestation in lib.pipelinerun_attestations - task := tkn.build_task(attestation) + some task in tkn.build_tasks(attestation) value := tkn.task_param(task, "HERMETIC") } diff --git a/policy/release/hermetic_build_task_test.rego b/policy/release/hermetic_build_task_test.rego index 0916a5ea..2e2e1a01 100644 --- a/policy/release/hermetic_build_task_test.rego +++ b/policy/release/hermetic_build_task_test.rego @@ -27,6 +27,69 @@ test_not_hermetic_build if { lib.assert_equal_results(expected, hermetic_build_task.deny) with input.attestations as [hermetic_missing] } +test_hermetic_build_many_build_tasks if { + task1 := { + "results": [ + {"name": "IMAGE_URL", "value": "registry/repo"}, + {"name": "IMAGE_DIGEST", "value": "digest"}, + ], + "ref": {"kind": "Task", "name": "build-1", "bundle": "reg.img/spam@sha256:abc"}, + "invocation": {"parameters": {"HERMETIC": "true"}}, + } + + task2 := { + "results": [ + {"name": "IMAGE_URL", "value": "registry/repo"}, + {"name": "IMAGE_DIGEST", "value": "digest"}, + ], + "ref": {"kind": "Task", "name": "build-2", "bundle": "reg.img/spam@sha256:abc"}, + "invocation": {"parameters": {"HERMETIC": "true"}}, + } + + attestation := {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, task2]}, + }}} + lib.assert_empty(hermetic_build_task.deny) with input.attestations as [attestation] + + attestation_mixed_hermetic := json.patch( + {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, task2]}, + }}}, + [{ + "op": "replace", + "path": "/statement/predicate/buildConfig/tasks/0/invocation/parameters/HERMETIC", + "value": "false", + }], + ) + expected := {{ + "code": "hermetic_build_task.build_task_hermetic", + "msg": "Build task was not invoked with the hermetic parameter set", + }} + lib.assert_equal_results(expected, hermetic_build_task.deny) with input.attestations as [attestation_mixed_hermetic] + + attestation_non_hermetic := json.patch( + {"statement": {"predicate": { + "buildType": lib.tekton_pipeline_run, + "buildConfig": {"tasks": [task1, task2]}, + }}}, + [ + { + "op": "replace", + "path": "/statement/predicate/buildConfig/tasks/0/invocation/parameters/HERMETIC", + "value": "false", + }, + { + "op": "replace", + "path": "/statement/predicate/buildConfig/tasks/1/invocation/parameters/HERMETIC", + "value": "false", + }, + ], + ) + lib.assert_equal_results(expected, hermetic_build_task.deny) with input.attestations as [attestation_non_hermetic] +} + _good_attestation := {"statement": {"predicate": { "buildType": lib.tekton_pipeline_run, "buildConfig": {"tasks": [{ diff --git a/policy/release/provenance_materials.rego b/policy/release/provenance_materials.rego index 71f38b26..7c677031 100644 --- a/policy/release/provenance_materials.rego +++ b/policy/release/provenance_materials.rego @@ -31,7 +31,7 @@ import data.lib.tkn # deny contains result if { some attestation in lib.pipelinerun_attestations - not tkn.git_clone_task(attestation) + count(tkn.git_clone_tasks(attestation)) == 0 result := lib.result_helper(rego.metadata.chain(), []) } @@ -56,9 +56,9 @@ deny contains result if { deny contains result if { some attestation in lib.pipelinerun_attestations - t := tkn.git_clone_task(attestation) - url := _normalize_git_url(tkn.task_result(t, "url")) - commit := tkn.task_result(t, "commit") + some task in tkn.git_clone_tasks(attestation) + url := _normalize_git_url(tkn.task_result(task, "url")) + commit := tkn.task_result(task, "commit") materials := [m | some m in attestation.statement.predicate.materials diff --git a/policy/release/provenance_materials_test.rego b/policy/release/provenance_materials_test.rego index 5b2df07e..93513c99 100644 --- a/policy/release/provenance_materials_test.rego +++ b/policy/release/provenance_materials_test.rego @@ -154,6 +154,46 @@ test_commit_and_url_mismatch if { lib.assert_equal_results(expected, provenance_materials.deny) with input.attestations as [_mock_attestation(tasks)] } +test_provenance_many_git_clone_tasks if { + task := { + "results": [ + {"name": "url", "value": _git_url}, + {"name": "commit", "value": _git_commit}, + ], + "ref": {"bundle": _bundle}, + "steps": [{"entrypoint": "/bin/bash"}], + } + + task1 := json.patch(task, [{ + "op": "add", + "path": "name", + "value": "git-clone-1", + }]) + + task2 := json.patch(task, [{ + "op": "add", + "path": "name", + "value": "git-clone-2", + }]) + + attestation := _mock_attestation([task1, task2]) + + # all good + lib.assert_empty(provenance_materials.deny) with input.attestations as [attestation] + + # one task's cloned digest doesn't match + expected := {{ + "code": "provenance_materials.git_clone_source_matches_provenance", + # regal ignore:line-length + "msg": `Entry in materials for the git repo "git+https://gitforge/repo.git" and commit "big-bada-boom" not found`, + }} + lib.assert_equal_results(expected, provenance_materials.deny) with input.attestations as [json.patch(attestation, [{ + "op": "replace", + "path": "/statement/predicate/buildConfig/tasks/0/results/1/value", + "value": "big-bada-boom", + }])] +} + _bundle := "registry.img/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb" _git_url := "https://gitforge/repo" diff --git a/policy/release/slsa_build_scripted_build.rego b/policy/release/slsa_build_scripted_build.rego index 20015a65..861e2bbf 100644 --- a/policy/release/slsa_build_scripted_build.rego +++ b/policy/release/slsa_build_scripted_build.rego @@ -40,7 +40,8 @@ import data.lib.tkn # deny contains result if { some attestation in lib.pipelinerun_attestations - build_task := tkn.build_task(attestation) + build_tasks := tkn.build_tasks(attestation) + some build_task in build_tasks count(task_steps(build_task)) == 0 result := lib.result_helper(rego.metadata.chain(), [build_task.name]) } @@ -64,7 +65,7 @@ deny contains result if { # deny contains result if { some attestation in lib.pipelinerun_attestations - not tkn.build_task(attestation) + count(tkn.build_tasks(attestation)) == 0 result := lib.result_helper(rego.metadata.chain(), []) } @@ -75,7 +76,7 @@ deny contains result if { # IMAGE_URL values from the build task. # custom: # short_name: subject_build_task_matches -# failure_msg: The attestation subject, %q, does not match the build task image, %q +# failure_msg: The attestation subject, %q, does not match any of the images built # solution: >- # Make sure the subject in the attestation matches the 'IMAGE_URL' and 'IMAGE_DIGEST' # results from the build task. The format for the subject should be 'IMAGE_URL@IMAGE_DIGEST'. @@ -87,19 +88,28 @@ deny contains result if { # deny contains result if { some attestation in lib.pipelinerun_attestations - build_task := tkn.build_task(attestation) - some subject in attestation.statement.subject + build_tasks := tkn.build_tasks(attestation) + + count(build_tasks) > 0 + subject_image_ref := concat("@", [subject.name, subject_digest(subject)]) - result_image_ref := concat("@", [ - tkn.task_result(build_task, "IMAGE_URL"), - tkn.task_result(build_task, "IMAGE_DIGEST"), - ]) - not image.equal_ref(subject_image_ref, result_image_ref) + matched := [subject_image_ref | + some build_task in build_tasks + + result_image_ref := concat("@", [ + tkn.task_result(build_task, "IMAGE_URL"), + tkn.task_result(build_task, "IMAGE_DIGEST"), + ]) + + image.equal_ref(subject_image_ref, result_image_ref) + ] + + count(matched) == 0 - result := lib.result_helper(rego.metadata.chain(), [subject_image_ref, result_image_ref]) + result := lib.result_helper(rego.metadata.chain(), [subject_image_ref]) } task_steps(task) := steps if { diff --git a/policy/release/slsa_build_scripted_build_test.rego b/policy/release/slsa_build_scripted_build_test.rego index ef43a565..91e6f423 100644 --- a/policy/release/slsa_build_scripted_build_test.rego +++ b/policy/release/slsa_build_scripted_build_test.rego @@ -91,6 +91,73 @@ test_empty_task_steps if { ) with input.attestations as [_mock_attestation(tasks)] } +test_build_script_used_many_build_tasks if { + tasks := [ + { + "name": "build-1", + "results": [ + {"name": "IMAGE_URL", "value": _image_url}, + {"name": "IMAGE_DIGEST", "value": _image_digest}, + ], + "ref": {"bundle": mock_bundle}, + "steps": [{"entrypoint": "/bin/bash"}], + }, + { + "name": "build-2", + "results": [ + {"name": "IMAGE_URL", "value": _image_url}, + {"name": "IMAGE_DIGEST", "value": _image_digest}, + ], + "ref": {"bundle": mock_bundle}, + "steps": [{"entrypoint": "/bin/bash"}], + }, + ] + + # all good + lib.assert_empty(slsa_build_scripted_build.deny) with input.attestations as [_mock_attestation(tasks)] + + # one of the build tasks doesn't have any steps + expected_scripted := {{ + "code": "slsa_build_scripted_build.build_script_used", + "msg": "Build task \"build-2\" does not contain any steps", + }} + lib.assert_equal_results( + expected_scripted, + slsa_build_scripted_build.deny, + ) with input.attestations as [_mock_attestation(json.patch(tasks, [{ + "op": "remove", + "path": "1/steps", + }]))] + + # one of the build tasks produces the expected results, the other one doesn't, this is ok + lib.assert_empty(slsa_build_scripted_build.deny) with input.attestations as [_mock_attestation(json.patch(tasks, [{ + "op": "replace", + "path": "1/results/0/value", + "value": "something-else", + }]))] + + # none of the build tasks produced the expected results + expected_results := {{ + "code": "slsa_build_scripted_build.subject_build_task_matches", + "msg": `The attestation subject, "some.image/foo:bar@sha256:123", does not match any of the images built`, + }} + lib.assert_equal_results( + expected_results, + slsa_build_scripted_build.deny, + ) with input.attestations as [_mock_attestation(json.patch(tasks, [ + { + "op": "replace", + "path": "0/results/0/value", + "value": "something-else", + }, + { + "op": "replace", + "path": "1/results/0/value", + "value": "something-else", + }, + ]))] +} + test_results_missing_value_url if { tasks := [{ "results": [ @@ -187,8 +254,7 @@ test_subject_mismatch if { expected := {{ "code": "slsa_build_scripted_build.subject_build_task_matches", - # regal ignore:line-length - "msg": `The attestation subject, "some.image/foo:bar@sha256:123", does not match the build task image, "some.image/foo:bar@sha256:anotherdigest"`, + "msg": `The attestation subject, "some.image/foo:bar@sha256:123", does not match any of the images built`, }} lib.assert_equal_results( @@ -254,7 +320,7 @@ test_subject_with_tag_and_digest_mismatch_digest_fails if { expected := {{ "code": "slsa_build_scripted_build.subject_build_task_matches", # regal ignore:line-length - "msg": `The attestation subject, "registry.io/repository/image@sha256:unexpected", does not match the build task image, "registry.io/repository/image:tag@sha256:digest"`, + "msg": `The attestation subject, "registry.io/repository/image@sha256:unexpected", does not match any of the images built`, }} lib.assert_equal_results(expected, slsa_build_scripted_build.deny) with input.attestations as [{"statement": {