diff --git a/metadata/testing.json b/metadata/testing.json index 99b0845..f9e5222 100644 --- a/metadata/testing.json +++ b/metadata/testing.json @@ -2,11 +2,11 @@ "signatures": [ { "keyid": "6132f1f2dd14bf3e9ba1a8df4c8435a77d2fd57f4a99bbb699ae61f85907818e", - "sig": "3065023063afeb04a3b1edab69442506469956fd98d7d696a12bf44a391d72560b642ad55ecea91dedc26568d1a61fe9cb98531d023100e5e1c9fe067ee69a84fc173cacb4c3ba2bcd54c81569906f05c0843e0e67231cec9679075fb7c2b0b73d4b1b99e34d7d" + "sig": "3065023100aa9a7680499a5f6a862f0e4a0febc78327ae6fd6418934d29262e1ad0cf3f3fb43a1400e4e1b557b0e1b4bedaa9a3f78023007cb176d2178e0c09e12ce8ca08a362a6a123b365ed8d9a0c81911d22195a8e522210bc35079d12916590430c9e45769" }, { "keyid": "f2149d8b7c1ece56d87d81f27fa68b745efc841892b3acfa382ad7f611e612ec", - "sig": "30660231008f40ff5258971c959f7b6498462a72ec2a4a6076ce8eb31a97d9a016d38a36be9471176099d6b6feb7b54db2b86a22f402310089db9c8a83691e44dbeecf5e62c8f464f61c71f3dcafa0a4387e4ea78a2b798f849c6a9f2874aeaed438e74547554a5c" + "sig": "" }, { "keyid": "7f4720651538fe96ce3067befcea395eccd49aa1a509568122eaadb300b0f880", @@ -26,22 +26,46 @@ }, { "keyid": "da52a1fb41a0a1f71cba47032f1074a4dc380aef4f3c53553a95ce54e60942ee", - "sig": "30650230469c25851466863081eb32874909479437350cd789a819d8aa093ddf25341795a64d069f176001b73fcba9537646d39f02310084076fd3e45454a72e05b39124b864ef7659dccecdb170a9b6ead81b6865ee0d64847909570f7a5667a5e8bfa96a97f9" + "sig": "306502303c0f424ade4e5bfcfc7cb83fc5f6268acae6ec50e7a88a6fc4df8d0745999755ad365df16fc0b78e60d72d6e1a3720d8023100b47320ccecddc581b69ce65a134b5308f3cc6d86d6051cd06aa743bfaeda34dedb9ffa6498d701003f7d060d8a5e2e62" } ], "signed": { "_type": "targets", - "expires": "2025-09-18T20:44:40Z", + "expires": "2025-09-24T09:23:59Z", "spec_version": "1.0.31", "targets": { + "testing/doi/policy-full.rego": { + "hashes": { + "sha256": "b8e1b940cce51ecef546a96d202cbfe000c40441a3869e5cc5790b27d35bce65" + }, + "length": 7972 + }, + "testing/doi/policy-vsa.rego": { + "hashes": { + "sha256": "c154c5cfc97c689534c1399e75eea72208a5cb04abd38f5627005db0742d4b38" + }, + "length": 6114 + }, + "testing/mapping.yaml": { + "hashes": { + "sha256": "5b10a0858bf3446c4650abb938d989e4400f738400a626cba01b04eef1e6985b" + }, + "length": 398 + }, "testing/rekor/c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d.pem": { "hashes": { "sha256": "dce5ef715502ec9f3cdfd11f8cc384b31a6141023d3e7595e9908a81cb6241bd" }, "length": 178 + }, + "testing/version-constraints": { + "hashes": { + "sha256": "be976ac935d635fb38588d36cbd9cf159ed1a0176779737c687439b567e609b0" + }, + "length": 12 } }, - "version": 2, + "version": 3, "x-tuf-on-ci-expiry-period": 365, "x-tuf-on-ci-signing-period": 60 } diff --git a/targets/testing/doi/policy-full.rego b/targets/testing/doi/policy-full.rego new file mode 100644 index 0000000..daa0640 --- /dev/null +++ b/targets/testing/doi/policy-full.rego @@ -0,0 +1,271 @@ +# doi/policy-full.rego verifies and validates the provenance and SBOM attestations attached to the image +package attest + +import rego.v1 + +split_digest := split(input.digest, ":") + +digest_type := split_digest[0] + +digest := split_digest[1] + +keys := [{ + "id": "11681ba744a6b4efa85132e884e56a6e6aa6dcde123fbc4e79fd3fb2e1cf186b", # production + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFwhMAaawBNowyj/w35RtAqeWllCe\nKt83A6nxMnQfVYFEHTWPj9EvgV03ogMy63i/9Hfi2lWihO+4g2vzSS02Gg==\n-----END PUBLIC KEY-----", + "from": "2023-05-28T19:25:00Z", + "to": null, + "status": "active", + "distrust": false, + "signing-format": "dssev1", +}] + +verify_opts := {"keys": keys} + +verify_attestation(att) := attest.verify(att, verify_opts) + +provenance_attestations contains att if { + result := attest.fetch("https://slsa.dev/provenance/v0.2") + not result.error + some att in result.value +} + +provenance_signed_statements contains statement if { + some att in provenance_attestations + result := verify_attestation(att) + not result.error + statement := result.value +} + +provenance_statements_with_subject contains statement if { + some statement in provenance_signed_statements + some subject in statement.subject + subject.digest[digest_type] == digest + valid_subject_name(input.isCanonical, subject.name, input.purl) +} + +provenance_subjects contains subject if { + some statement in provenance_statements_with_subject + some subject in statement.subject +} + +# we need to key this by statement_id rather than statement because we can't +# use an object as a key due to a bug(?) in OPA: https://github.com/open-policy-agent/opa/issues/6736 +provenance_statement_violations[statement_id] contains v if { + some att in provenance_attestations + result := verify_attestation(att) + err := result.error + statement := unsafe_statement_from_attestation(att) + statement_id := id(statement) + v := { + "type": "unsigned_statement", + "description": sprintf("Statement is not correctly signed: %v", [err]), + "attestation": statement, + "details": {"error": err}, + } +} + +provenance_statement_violations[statement_id] contains v if { + some statement in provenance_signed_statements + statement_id := id(statement) + not statement in provenance_statements_with_subject + v := { + "type": "bad_subjects", + "description": "provenance statement does not have this image as a subject", + "attestation": statement, + "details": {"input": input}, + } +} + +provenance_statement_violations[statement_id] contains v if { + some statement in provenance_statements_with_subject + statement_id := id(statement) + v := field_value_does_not_equal(statement, "buildType", "https://mobyproject.org/buildkit@v1", "wrong_build_type") +} + +provenance_statement_violations[statement_id] contains v if { + some statement in provenance_statements_with_subject + statement_id := id(statement) + v := field_value_does_not_equal(statement, "metadata.completeness.materials", true, "incomplete_materials") +} + +bad_provenance_statements contains statement if { + some statement in provenance_statements_with_subject + statement_id := id(statement) + provenance_statement_violations[statement_id] +} + +good_provenance_statements := provenance_statements_with_subject - bad_provenance_statements + +sbom_attestations contains att if { + result := attest.fetch("https://spdx.dev/Document") + not result.error + some att in result.value +} + +sbom_signed_statements contains statement if { + some att in sbom_attestations + result := verify_attestation(att) + not result.error + statement := result.value +} + +sbom_statements_with_subject contains statement if { + some statement in sbom_signed_statements + some subject in statement.subject + subject.digest[digest_type] == digest + valid_subject_name(input.isCanonical, subject.name, input.purl) +} + +sbom_subjects contains subject if { + some statement in sbom_statements_with_subject + some subject in statement.subject +} + +# we need to key this by statement_id rather than statement because we can't +# use an object as a key due to a bug(?) in OPA: https://github.com/open-policy-agent/opa/issues/6736 +sbom_statement_violations[statement_id] contains v if { + some att in sbom_attestations + result := verify_attestation(att) + err := result.error + statement := unsafe_statement_from_attestation(att) + statement_id := id(statement) + v := { + "type": "unsigned_statement", + "description": sprintf("Statement is not correctly signed: %v", [err]), + "attestation": statement, + "details": {"error": err}, + } +} + +sbom_statement_violations[statement_id] contains v if { + some statement in sbom_signed_statements + statement_id := id(statement) + not statement in sbom_statements_with_subject + v := { + "type": "bad_subjects", + "description": "SBOM statement does not have this image as a subject", + "attestation": statement, + "details": {"input": input}, + } +} + +sbom_statement_violations[statement_id] contains v if { + some statement in sbom_statements_with_subject + statement_id := id(statement) + v := field_value_does_not_equal(statement, "SPDXID", "SPDXRef-DOCUMENT", "wrong_spdx_id") +} + +bad_sbom_statements contains statement if { + some statement in sbom_statements_with_subject + statement_id := id(statement) + sbom_statement_violations[statement_id] +} + +good_sbom_statements := sbom_statements_with_subject - bad_sbom_statements + +global_violations contains v if { + count(sbom_attestations) == 0 + v := { + "type": "missing_attestation", + "description": "No https://slsa.dev/provenance/v0.2 attestation found", + "attestation": null, + "details": {}, + } +} + +global_violations contains v if { + count(provenance_attestations) == 0 + v := { + "type": "missing_attestation", + "description": "No https://spdx.dev/Document attestation found", + "attestation": null, + "details": {}, + } +} + +all_violations contains v if { + some v in global_violations +} + +all_violations contains v if { + some violations in sbom_statement_violations + some v in violations +} + +all_violations contains v if { + some violations in provenance_statement_violations + some v in violations +} + +subjects := union({sbom_subjects, provenance_subjects}) + +result := { + "success": allow, + "violations": all_violations, + "summary": { + "subjects": subjects, + "slsa_levels": ["SLSA_BUILD_LEVEL_3"], + "verifier": "docker-official-images", + "policy_uri": "https://docker.com/official/policy/v0.1", + }, +} + +default allow := false + +allow if { + count(good_sbom_statements) > 0 + count(good_provenance_statements) > 0 +} + +id(statement) := crypto.sha256(json.marshal(statement)) + +# TODO: this should take into account the repo name from the purl +valid_subject_name(true, name, purl) + +valid_subject_name(false, name, purl) if { + name == purl +} + +field_value_does_not_equal(statement, field, expected, type) := v if { + path := split(field, ".") + actual := object.get(statement.predicate, path, null) + expected != actual + v := is_not_violation(statement, field, expected, actual, type) +} + +array_field_does_not_contain(statement, field, expected, type) := v if { + path := split(field, ".") + actual := object.get(statement.predicate, path, null) + not expected in actual + v := not_contains_violation(statement, field, expected, actual, type) +} + +is_not_violation(statement, field, expected, actual, type) := { + "type": type, + "description": sprintf("%v is not %v", [field, expected]), + "attestation": statement, + "details": { + "field": field, + "actual": actual, + "expected": expected, + }, +} + +not_contains_violation(statement, field, expected, actual, type) := { + "type": type, + "description": sprintf("%v does not contain %v", [field, expected]), + "attestation": statement, + "details": { + "field": field, + "actual": actual, + "expected": expected, + }, +} + +# This is unsafe because we're not checking the signature on the attestation, +# do not call this unless you've already verified the attestation or you need the +# statement for some other reason +unsafe_statement_from_attestation(att) := statement if { + payload := att.payload + statement := json.unmarshal(base64.decode(payload)) +} diff --git a/targets/testing/doi/policy-vsa.rego b/targets/testing/doi/policy-vsa.rego new file mode 100644 index 0000000..2abc1b0 --- /dev/null +++ b/targets/testing/doi/policy-vsa.rego @@ -0,0 +1,203 @@ +# doi/policy-vsa.rego verifies and validates the Verification Summary Attestation (VSA) attached to the image. +# The VSA is generated by evaluating doi/policy-full.rego and is a verification summary of all attestations. +package attest + +import rego.v1 + +split_digest := split(input.digest, ":") + +digest_type := split_digest[0] + +digest := split_digest[1] + +keys := [{ + "id": "11681ba744a6b4efa85132e884e56a6e6aa6dcde123fbc4e79fd3fb2e1cf186b", # production + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFwhMAaawBNowyj/w35RtAqeWllCe\nKt83A6nxMnQfVYFEHTWPj9EvgV03ogMy63i/9Hfi2lWihO+4g2vzSS02Gg==\n-----END PUBLIC KEY-----", + "from": "2023-05-28T19:25:00Z", + "to": null, + "status": "active", + "distrust": false, + "signing-format": "dssev1", +}] + +verify_opts := {"keys": keys} + +verify_attestation(att) := attest.verify(att, verify_opts) + +attestations contains att if { + result := attest.fetch("https://slsa.dev/verification_summary/v1") + not result.error + some att in result.value +} + +signed_statements contains statement if { + some att in attestations + result := verify_attestation(att) + not result.error + statement := result.value +} + +statements_with_subject contains statement if { + some statement in signed_statements + some subject in statement.subject + subject.digest[digest_type] == digest + valid_subject_name(input.isCanonical, subject.name, input.purl) +} + +id(statement) := crypto.sha256(json.marshal(statement)) + +subjects contains subject if { + some statement in statements_with_subject + some subject in statement.subject +} + +global_violations contains v if { + count(attestations) == 0 + v := { + "type": "missing_attestation", + "description": "No https://slsa.dev/verification_summary/v1 attestation found", + "attestation": null, + "details": {}, + } +} + +# we need to key this by statement_id rather than statement because we can't +# use an object as a key due to a bug(?) in OPA: https://github.com/open-policy-agent/opa/issues/6736 +statement_violations[statement_id] contains v if { + some att in attestations + result := verify_attestation(att) + err := result.error + statement := unsafe_statement_from_attestation(att) + statement_id := id(statement) + v := { + "type": "unsigned_statement", + "description": sprintf("Statement is not correctly signed: %v", [err]), + "attestation": statement, + "details": {"error": err}, + } +} + +statement_violations[statement_id] contains v if { + some statement in signed_statements + statement_id := id(statement) + not statement in statements_with_subject + v := { + "type": "bad_subjects", + "description": "Statement does not have this image as a subject", + "attestation": statement, + "details": {"input": input}, + } +} + +statement_violations[statement_id] contains v if { + some statement in statements_with_subject + statement_id := id(statement) + v := field_value_does_not_equal(statement, "verificationResult", "PASSED", "wrong_verification_result") +} + +# TODO: add to statement_violations if there are statements that have an incorrect resource_uri +# this should match the input.purl, but we really only care about the repo name and the digest +# we need to receive the input.purl as a parsed object so we can compare only the parts we care about + +statement_violations[statement_id] contains v if { + some statement in statements_with_subject + statement_id := id(statement) + v := field_value_does_not_equal(statement, "verifier.id", "docker-official-images", "wrong_verifier") +} + +statement_violations[statement_id] contains v if { + some statement in statements_with_subject + statement_id := id(statement) + v := field_value_does_not_equal(statement, "policy.uri", "https://docker.com/official/policy/v0.1", "wrong_policy_uri") +} + +statement_violations[statement_id] contains v if { + some statement in statements_with_subject + statement_id := id(statement) + v := array_field_does_not_contain(statement, "verifiedLevels", "SLSA_BUILD_LEVEL_3", "wrong_verified_levels") +} + +bad_statements contains statement if { + some statement in statements_with_subject + statement_id := id(statement) + statement_violations[statement_id] +} + +good_statements := statements_with_subject - bad_statements + +all_violations contains v if { + some v in global_violations +} + +all_violations contains v if { + some violations in statement_violations + some v in violations +} + +result := { + "success": allow, + "violations": all_violations, + "summary": { + "subjects": subjects, + "slsa_levels": ["SLSA_BUILD_LEVEL_3"], + "verifier": "docker-official-images", + "policy_uri": "https://docker.com/official/policy/v0.1", + }, +} + +default allow := false + +allow if { + count(good_statements) > 0 +} + +# TODO: this should take into account the repo name from the purl +valid_subject_name(true, name, purl) + +valid_subject_name(false, name, purl) if { + name == purl +} + +field_value_does_not_equal(statement, field, expected, type) := v if { + path := split(field, ".") + actual := object.get(statement.predicate, path, null) + expected != actual + v := is_not_violation(statement, field, expected, actual, type) +} + +array_field_does_not_contain(statement, field, expected, type) := v if { + path := split(field, ".") + actual := object.get(statement.predicate, path, null) + not expected in actual + v := not_contains_violation(statement, field, expected, actual, type) +} + +is_not_violation(statement, field, expected, actual, type) := { + "type": type, + "description": sprintf("%v is not %v", [field, expected]), + "attestation": statement, + "details": { + "field": field, + "actual": actual, + "expected": expected, + }, +} + +not_contains_violation(statement, field, expected, actual, type) := { + "type": type, + "description": sprintf("%v does not contain %v", [field, expected]), + "attestation": statement, + "details": { + "field": field, + "actual": actual, + "expected": expected, + }, +} + +# This is unsafe because we're not checking the signature on the attestation, +# do not call this unless you've already verified the attestation or you need the +# statement for some other reason +unsafe_statement_from_attestation(att) := statement if { + payload := att.payload + statement := json.unmarshal(base64.decode(payload)) +} diff --git a/targets/testing/mapping.yaml b/targets/testing/mapping.yaml new file mode 100644 index 0000000..63c214c --- /dev/null +++ b/targets/testing/mapping.yaml @@ -0,0 +1,15 @@ +# map repos to policies +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images (VSA) + files: + - path: doi/policy-vsa.rego + - id: docker-official-images-full + description: Docker Official Images + files: + - path: doi/policy-full.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images diff --git a/targets/testing/version-constraints b/targets/testing/version-constraints new file mode 100644 index 0000000..f88cb53 --- /dev/null +++ b/targets/testing/version-constraints @@ -0,0 +1 @@ +>= v0.1.7-0