From b25335d075567252e3a89a70f09aad982e56c2dd Mon Sep 17 00:00:00 2001 From: Zoran Regvart Date: Wed, 16 Oct 2024 16:42:15 +0200 Subject: [PATCH 1/3] Use of clair report from OCI and CVE leeway If present the Clair report attached to the image will be used and consulted instead of the aggregate present in `SCAN_OUTPUT` Task result. This allows us to filter out the vulnerabilities that are made public within X number of leeway days. Reference: https://issues.redhat.com/browse/EC-838 --- .../modules/ROOT/pages/release_policy.adoc | 18 +- policy/lib/rule_data.rego | 7 + policy/release/cve/cve.rego | 70 ++++- policy/release/cve/cve_test.rego | 263 ++++++++++++++++++ 4 files changed, 347 insertions(+), 11 deletions(-) diff --git a/antora/docs/modules/ROOT/pages/release_policy.adoc b/antora/docs/modules/ROOT/pages/release_policy.adoc index e32ee4a9..a80c671f 100644 --- a/antora/docs/modules/ROOT/pages/release_policy.adoc +++ b/antora/docs/modules/ROOT/pages/release_policy.adoc @@ -395,26 +395,26 @@ This package is responsible for verifying a CVE scan was performed during the bu [#cve__cve_blockers] === link:#cve__cve_blockers[Blocking CVE check] -The SLSA Provenance attestation for the image is inspected to ensure CVEs that have a known fix and meet a certain security level have not been detected. If detected, this policy rule will fail. By default, only CVEs of critical and high security level cause a failure. This is configurable by the rule data key `restrict_cve_security_levels`. The available levels are critical, high, medium, low, and unknown. +The SLSA Provenance attestation for the image is inspected to ensure CVEs that have a known fix and meet a certain security level have not been detected. If detected, this policy rule will fail. By default, only CVEs of critical and high security level cause a failure. This is configurable by the rule data key `restrict_cve_security_levels`. The available levels are critical, high, medium, low, and unknown. In addition to that leeway can be granted per severity using the `cve_leeway` rule data key containing days of allowed leeway, measured as time between found vulnerability's public disclosure date and current effective time, per severity level. *Solution*: Make sure to address any CVE's related to the image. The CVEs are detected by the task that runs a Clair scan and emits a result named `SCAN_OUTPUT`. * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `Found %d CVE vulnerabilities of %s security level` * Code: `cve.cve_blockers` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L88[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L90[Source, window="_blank"] [#cve__unpatched_cve_blockers] === link:#cve__unpatched_cve_blockers[Blocking unpatched CVE check] -The SLSA Provenance attestation for the image is inspected to ensure CVEs that do NOT have a known fix and meet a certain security level have not been detected. If detected, this policy rule will fail. By default, the list of security levels used by this policy is empty. This is configurable by the rule data key `restrict_unpatched_cve_security_levels`. The available levels are critical, high, medium, low, and unknown. +The SLSA Provenance attestation for the image is inspected to ensure CVEs that do NOT have a known fix and meet a certain security level have not been detected. If detected, this policy rule will fail. By default, the list of security levels used by this policy is empty. This is configurable by the rule data key `restrict_unpatched_cve_security_levels`. The available levels are critical, high, medium, low, and unknown. In addition to that leeway can be granted per severity using the `cve_leeway` rule data key containing days of allowed leeway, measured as time between found vulnerability's public disclosure date and current effective time, per severity level. *Solution*: CVEs without a known fix can only be remediated by either removing the impacted dependency, or by waiting for a fix to be available. The CVEs are detected by the task that emits a result named `SCAN_OUTPUT`. * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `Found %d unpatched CVE vulnerabilities of %s security level` * Code: `cve.unpatched_cve_blockers` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L113[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L117[Source, window="_blank"] [#cve__cve_results_found] === link:#cve__cve_results_found[CVE scan results found] @@ -426,7 +426,7 @@ Confirm that clair-scan task results are present in the SLSA Provenance attestat * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `Clair CVE scan results were not found` * Code: `cve.cve_results_found` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L139[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L146[Source, window="_blank"] [#cve__deprecated_cve_result_name] === link:#cve__deprecated_cve_result_name[Deprecated CVE result name] @@ -438,7 +438,7 @@ The `CLAIR_SCAN_RESULT` result name has been deprecated, and has been replaced w * Rule type: [rule-type-indicator warning]#WARNING# * WARNING message: `CVE scan uses deprecated result name` * Code: `cve.deprecated_cve_result_name` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L66[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L68[Source, window="_blank"] [#cve__cve_warnings] === link:#cve__cve_warnings[Non-blocking CVE check] @@ -450,7 +450,7 @@ The SLSA Provenance attestation for the image is inspected to ensure CVEs that h * Rule type: [rule-type-indicator warning]#WARNING# * WARNING message: `Found %d non-blocking CVE vulnerabilities of %s security level` * Code: `cve.cve_warnings` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L15[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L17[Source, window="_blank"] [#cve__unpatched_cve_warnings] === link:#cve__unpatched_cve_warnings[Non-blocking unpatched CVE check] @@ -462,7 +462,7 @@ The SLSA Provenance attestation for the image is inspected to ensure CVEs that d * Rule type: [rule-type-indicator warning]#WARNING# * WARNING message: `Found %d non-blocking unpatched CVE vulnerabilities of %s security level` * Code: `cve.unpatched_cve_warnings` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L40[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L42[Source, window="_blank"] [#cve__rule_data_provided] === link:#cve__rule_data_provided[Rule data provided] @@ -474,7 +474,7 @@ Confirm the expected rule data keys have been provided in the expected format. T * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `%s` * Code: `cve.rule_data_provided` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L164[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L171[Source, window="_blank"] [#external_parameters_package] == link:#external_parameters_package[External parameters] diff --git a/policy/lib/rule_data.rego b/policy/lib/rule_data.rego index 6657f996..1315cf1f 100644 --- a/policy/lib/rule_data.rego +++ b/policy/lib/rule_data.rego @@ -35,6 +35,13 @@ rule_data_defaults := { "warn_cve_security_levels": [], "restrict_unpatched_cve_security_levels": [], "warn_unpatched_cve_security_levels": ["critical", "high"], + "cve_leeway": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unknown": 0, + }, # Used in policy/release/slsa_source_correlated.rego # According to https://pip.pypa.io/en/latest/topics/vcs-support/#vcs-support # and https://spdx.dev/spdx-specification-20-web-version/#h.49x2ik5 diff --git a/policy/release/cve/cve.rego b/policy/release/cve/cve.rego index 3fd7654f..1ea40feb 100644 --- a/policy/release/cve/cve.rego +++ b/policy/release/cve/cve.rego @@ -11,6 +11,8 @@ package release.cve import rego.v1 import data.lib +import data.lib.image +import data.lib.time as lib_time # METADATA # title: Non-blocking CVE check @@ -92,7 +94,9 @@ warn contains result if { # and meet a certain security level have not been detected. If detected, this policy rule will # fail. By default, only CVEs of critical and high security level cause a failure. This is # configurable by the rule data key `restrict_cve_security_levels`. The available levels are -# critical, high, medium, low, and unknown. +# critical, high, medium, low, and unknown. In addition to that leeway can be granted per severity +# using the `cve_leeway` rule data key containing days of allowed leeway, measured as time between +# found vulnerability's public disclosure date and current effective time, per severity level. # custom: # short_name: cve_blockers # failure_msg: Found %d CVE vulnerabilities of %s security level @@ -117,7 +121,10 @@ deny contains result if { # known fix and meet a certain security level have not been detected. If detected, this policy # rule will fail. By default, the list of security levels used by this policy is empty. This is # configurable by the rule data key `restrict_unpatched_cve_security_levels`. The available levels -# are critical, high, medium, low, and unknown. +# are critical, high, medium, low, and unknown. In addition to that leeway can be granted per +# severity using the `cve_leeway` rule data key containing days of allowed leeway, measured as +# time between found vulnerability's public disclosure date and current effective time, per +# severity level. # custom: # short_name: unpatched_cve_blockers # failure_msg: Found %d unpatched CVE vulnerabilities of %s security level @@ -181,12 +188,67 @@ deny contains result if { result := lib.result_helper(rego.metadata.chain(), [error]) } +# extracts the clair report attached to the image +_clair_report := report if { + input_image := image.parse(input.image.ref) + + some reports in lib.results_named(_reports_result_name) + report_image := object.union(input_image, {"digest": reports.value[input_image.digest]}) + report_ref := image.str(report_image) + report_manifest := ec.oci.image_manifest(report_ref) + + some layer in report_manifest.layers + layer.mediaType == _report_oci_mime_type + report_blob := object.union(input_image, {"digest": layer.digest}) + report_blob_ref := image.str(report_blob) + + report := json.unmarshal(ec.oci.blob(report_blob_ref)) +} + +# maps vulnerabilities and reports the counts by category (patched/unpatched) +# and severity +_clair_vulnerabilities[category] := vulns if { + reported_vulnerabilities := _clair_report.vulnerabilities + + some category, vulnerabilities in { + "vulnerabilities": [v | + some v in reported_vulnerabilities + v.fixed_in_version != "" + ], + "unpatched_vulnerabilities": [v | + some v in reported_vulnerabilities + v.fixed_in_version = "" + ], + } + + vulns := { + "critical": _count_by_severity_outside_leeway(vulnerabilities, "critical"), + "high": _count_by_severity_outside_leeway(vulnerabilities, "high"), + "medium": _count_by_severity_outside_leeway(vulnerabilities, "medium"), + "low": _count_by_severity_outside_leeway(vulnerabilities, "low"), + "unknown": _count_by_severity_outside_leeway(vulnerabilities, "unknown"), + } +} + +# counts the vulnerabilities with the given severity excluding vulnerabilities +# within the leeway period +_count_by_severity_outside_leeway(vulnerabilities, severity) := count([v | + some v in vulnerabilities + lower(v.normalized_severity) == severity + leeway_days := lib.rule_data("cve_leeway")[severity] + time.add_date(time.parse_rfc3339_ns(v.issued), 0, 0, leeway_days) < lib_time.effective_current_time_ns +]) + _vulnerabilities := vulnerabilities if { + vulnerabilities := _clair_vulnerabilities.vulnerabilities +} else := vulnerabilities if { some result in lib.results_named(_result_name) vulnerabilities := result.value.vulnerabilities } else := _vulnerabilities_deprecated _unpatched_vulnerabilities := vulnerabilities if { + vulnerabilities := _clair_vulnerabilities.unpatched_vulnerabilities +} else := vulnerabilities if { some result in lib.results_named(_result_name) vulnerabilities := result.value.unpatched_vulnerabilities } else := _unpatched_vulnerabilities_deprecated @@ -205,6 +267,10 @@ _result_name := "SCAN_OUTPUT" _deprecated_result_name := "CLAIR_SCAN_RESULT" +_reports_result_name := "REPORTS" + +_report_oci_mime_type := "application/vnd.redhat.clair-report+json" + _non_zero_vulnerabilities(key) := _non_zero_levels(key, _vulnerabilities) _non_zero_unpatched(key) := _non_zero_levels(key, _unpatched_vulnerabilities) diff --git a/policy/release/cve/cve_test.rego b/policy/release/cve/cve_test.rego index f2d26356..0e99bb34 100644 --- a/policy/release/cve/cve_test.rego +++ b/policy/release/cve/cve_test.rego @@ -4,6 +4,7 @@ import rego.v1 import data.lib import data.lib.tekton_test +import data.lib.time as lib_time import data.lib_test import data.release.cve @@ -503,6 +504,268 @@ test_rule_data_provided if { with data.rule_data as d } +test_clair_report if { + report := {"sha256:image_digest": "sha256:report_digest"} + + attestations := [lib_test.att_mock_helper_ref( + cve._reports_result_name, + report, + "clair-scan", + _bundle, + )] + + got := cve._clair_report with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as _mock_blob + + lib.assert_equal(_clair_report, got) +} + +test_clair_report_fetch_manifest_failure if { + report := {"sha256:image_digest": "sha256:report_digest"} + + attestations := [lib_test.att_mock_helper_ref( + cve._reports_result_name, + report, + "clair-scan", + _bundle, + )] + + not cve._clair_report with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as null + with ec.oci.blob as _mock_blob +} + +test_clair_report_fetch_blob_failure if { + report := {"sha256:image_digest": "sha256:report_digest"} + + attestations := [lib_test.att_mock_helper_ref( + cve._reports_result_name, + report, + "clair-scan", + _bundle, + )] + + not cve._clair_report with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as null +} + +test_clair_vulnerabilities if { + expected := { + "vulnerabilities": { + "critical": 1, + "high": 2, + "medium": 3, + "low": 4, + "unknown": 5, + }, + "unpatched_vulnerabilities": { + "critical": 6, + "high": 7, + "medium": 8, + "low": 9, + "unknown": 10, + }, + } + + got := cve._clair_vulnerabilities with cve._clair_report as _clair_report + + lib.assert_equal(expected, got) +} + +test_success_with_full_report if { + reports := {"sha256:image_digest": "sha256:no_vulnerabilities_report_digest"} + + slsav1_task_with_result := tekton_test.slsav1_task_result_ref( + "clair-scan", + [{ + "name": cve._reports_result_name, + "type": "string", + "value": reports, + }], + ) + attestations := [ + lib_test.att_mock_helper_ref( + cve._reports_result_name, + reports, + "clair-scan", + _bundle, + ), + lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), + ] + lib.assert_empty(cve.deny | cve.warn) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as _mock_blob +} + +test_failure_with_full_report if { + reports := {"sha256:image_digest": "sha256:report_digest"} + + slsav1_task_with_result := tekton_test.slsav1_task_result_ref( + "clair-scan", + [{ + "name": cve._reports_result_name, + "type": "string", + "value": reports, + }], + ) + attestations := [ + lib_test.att_mock_helper_ref( + cve._reports_result_name, + reports, + "clair-scan", + _bundle, + ), + lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), + ] + expected_deny := { + { + "code": "cve.cve_blockers", + "term": "critical", + "msg": "Found 1 CVE vulnerabilities of critical security level", + }, + { + "code": "cve.cve_blockers", + "term": "high", + "msg": "Found 2 CVE vulnerabilities of high security level", + }, + } + + # regal ignore:line-length + lib.assert_equal_results(cve.deny, expected_deny) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as _mock_blob +} + +test_full_report_fetch_issue if { + reports := {"sha256:image_digest": "sha256:no_vulnerabilities_report_digest"} + + slsav1_task_with_result := tekton_test.slsav1_task_result_ref( + "clair-scan", + [{ + "name": cve._reports_result_name, + "type": "string", + "value": reports, + }], + ) + attestations := [ + lib_test.att_mock_helper_ref( + cve._reports_result_name, + reports, + "clair-scan", + _bundle, + ), + lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), + ] + + expected := { + { + "code": "cve.cve_results_found", + "msg": "Clair CVE scan results were not found" + } + } + + lib.assert_equal_results(cve.deny, expected) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as null + lib.assert_equal_results(cve.deny, expected) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as null + + lib.assert_empty(cve.warn) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as null + lib.assert_empty(cve.warn) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as null +} + +test_warning_leeway_with_full_report if { + reports := {"sha256:image_digest": "sha256:report_digest"} + + slsav1_task_with_result := tekton_test.slsav1_task_result_ref( + "clair-scan", + [{ + "name": cve._reports_result_name, + "type": "string", + "value": reports, + }], + ) + attestations := [ + lib_test.att_mock_helper_ref( + cve._reports_result_name, + reports, + "clair-scan", + _bundle, + ), + lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), + ] + expected_deny := {{ + "code": "cve.cve_blockers", + "term": "critical", + "msg": "Found 1 CVE vulnerabilities of critical security level", + }} + + # regal ignore:line-length + lib.assert_equal_results(cve.deny, expected_deny) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as _mock_blob + with data.rule_data.cve_leeway as {"critical": 9, "high": 10} + with lib_time.effective_current_time_ns as time.parse_rfc3339_ns("2022-04-05T00:00:00Z") +} + +_fingerprints(a, b) := [v | some n in numbers.range(a, b); v := sprintf("%d", [n])] + +_vulns(fingerprits, template) := {v | + some fingerprint in fingerprits + v := {fingerprint: template} +} + +_vuln(severity, fixed_in, issued) := { + "fixed_in_version": fixed_in, + "normalized_severity": severity, + "issued": issued, +} + +# `opa fmt` is causing this +# regal ignore:line-length +vulnerabilities := object.union_n(lib.to_array(((((_vulns(_fingerprints(1, 1), _vuln("Critical", "1.0", "2022-03-26T00:00:00Z")) | _vulns(_fingerprints(2, 3), _vuln("High", "1.0", "2022-03-26T00:00:00Z"))) | _vulns(_fingerprints(4, 6), _vuln("Medium", "1.0", "2022-03-26T00:00:00Z"))) | _vulns(_fingerprints(7, 10), _vuln("Low", "1.0", "2022-03-26T00:00:00Z"))) | _vulns(_fingerprints(11, 15), _vuln("Unknown", "1.0", "2022-03-26T00:00:00Z"))))) + +# `opa fmt` is causing this +# regal ignore:line-length +unpatched_vulnerabilities := object.union_n(lib.to_array(((((_vulns(_fingerprints(16, 21), _vuln("Critical", "", "2022-03-26T00:00:00Z")) | _vulns(_fingerprints(22, 28), _vuln("High", "", "2022-03-26T00:00:00Z"))) | _vulns(_fingerprints(29, 36), _vuln("Medium", "", "2022-03-26T00:00:00Z"))) | _vulns(_fingerprints(37, 45), _vuln("Low", "", "2022-03-26T00:00:00Z"))) | _vulns(_fingerprints(46, 55), _vuln("Unknown", "", "2022-03-26T00:00:00Z"))))) + +_clair_report := {"vulnerabilities": object.union(vulnerabilities, unpatched_vulnerabilities)} + +_manifests := { + "registry.io/repository/image@sha256:report_digest": {"layers": [{ + "mediaType": cve._report_oci_mime_type, + "digest": "sha256:report_blob_digest", + }]}, + "registry.io/repository/image@sha256:no_vulnerabilities_report_digest": {"layers": [{ + "mediaType": cve._report_oci_mime_type, + "digest": "sha256:no_vulnerabilities_report_blob_digest", + }]}, +} + +_blobs := { + "registry.io/repository/image@sha256:report_blob_digest": json.marshal(_clair_report), + "registry.io/repository/image@sha256:no_vulnerabilities_report_blob_digest": json.marshal({"vulnerabilities": {}}), +} + +_mock_image_manifest(ref) := _manifests[ref] + +_mock_blob(ref) := _blobs[ref] + _bundle := "registry.img/spam@sha256:4e388ab32b10dc8dbc7e28144f552830adc74787c1e2c0824032078a79f227fb" _dummy_counts := {"critical": 1, "high": 10, "medium": 20, "low": 300, "unknown": 2} From a11fa3f58d6db76c2dbd9cf83bc1f3f5946b5af9 Mon Sep 17 00:00:00 2001 From: Zoran Regvart Date: Thu, 17 Oct 2024 13:29:19 +0200 Subject: [PATCH 2/3] Effective on based on leeway Sets the `effective_on` when vulnerabilities are not reported as blocking violations due to the leeway policy. Now the evaluation considers a period instead of the leeway cut-off date, so the functions/expressions can be reused between the different rules. This makes the violations resulting from vulnerabilities found within the leeway period effectively a warning. The zero period refers to the period with no leeway, i.e. from beginning of (Unix) time till the current effective date; and the configured period includes the time up to the leeway cut-off date, i.e. from beginning of (Unix) time up to the leeway cut-off date. Reference: https://issues.redhat.com/browse/EC-838 --- .../modules/ROOT/pages/release_policy.adoc | 6 +- policy/release/cve/cve.rego | 150 ++++++++++++++---- policy/release/cve/cve_test.rego | 89 +++++++++-- 3 files changed, 195 insertions(+), 50 deletions(-) diff --git a/antora/docs/modules/ROOT/pages/release_policy.adoc b/antora/docs/modules/ROOT/pages/release_policy.adoc index a80c671f..f6cf392e 100644 --- a/antora/docs/modules/ROOT/pages/release_policy.adoc +++ b/antora/docs/modules/ROOT/pages/release_policy.adoc @@ -414,7 +414,7 @@ The SLSA Provenance attestation for the image is inspected to ensure CVEs that d * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `Found %d unpatched CVE vulnerabilities of %s security level` * Code: `cve.unpatched_cve_blockers` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L117[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L130[Source, window="_blank"] [#cve__cve_results_found] === link:#cve__cve_results_found[CVE scan results found] @@ -426,7 +426,7 @@ Confirm that clair-scan task results are present in the SLSA Provenance attestat * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `Clair CVE scan results were not found` * Code: `cve.cve_results_found` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L146[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L172[Source, window="_blank"] [#cve__deprecated_cve_result_name] === link:#cve__deprecated_cve_result_name[Deprecated CVE result name] @@ -474,7 +474,7 @@ Confirm the expected rule data keys have been provided in the expected format. T * Rule type: [rule-type-indicator failure]#FAILURE# * FAILURE message: `%s` * Code: `cve.rule_data_provided` -* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L171[Source, window="_blank"] +* https://github.com/enterprise-contract/ec-policies/blob/{page-origin-refhash}/policy/release/cve/cve.rego#L197[Source, window="_blank"] [#external_parameters_package] == link:#external_parameters_package[External parameters] diff --git a/policy/release/cve/cve.rego b/policy/release/cve/cve.rego index 1ea40feb..5b033828 100644 --- a/policy/release/cve/cve.rego +++ b/policy/release/cve/cve.rego @@ -35,7 +35,7 @@ import data.lib.time as lib_time # - cve.cve_results_found # warn contains result if { - some level, amount in _non_zero_vulnerabilities("warn_cve_security_levels") + some level, amount in _non_zero_vulnerabilities("warn_cve_security_levels", _zero_period) result := lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level) } @@ -61,7 +61,7 @@ warn contains result if { # - cve.cve_results_found # warn contains result if { - some level, amount in _non_zero_unpatched("warn_unpatched_cve_security_levels") + some level, amount in _non_zero_unpatched("warn_unpatched_cve_security_levels", _zero_period) result := lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level) } @@ -110,8 +110,21 @@ warn contains result if { # - cve.cve_results_found # deny contains result if { - some level, amount in _non_zero_vulnerabilities("restrict_cve_security_levels") - result := lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level) + enforced_results := {result | + some level, amount in _non_zero_vulnerabilities("restrict_cve_security_levels", _configured_period) + result := lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level) + } + + leewayed_results := {result | + some level, amount in _leewayed_vulnerabilities("restrict_cve_security_levels") + + result := _with_effective_on( + lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level), + _configured_period[level].end, + ) + } + + some result in (enforced_results | leewayed_results) } # METADATA @@ -139,8 +152,21 @@ deny contains result if { # - cve.cve_results_found # deny contains result if { - some level, amount in _non_zero_unpatched("restrict_unpatched_cve_security_levels") - result := lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level) + enforced_results := {result | + some level, amount in _non_zero_unpatched("restrict_unpatched_cve_security_levels", _configured_period) + result := lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level) + } + + leewayed_results := {result | + some level, amount in _leewayed_unpatched_vulnerabilities("restrict_unpatched_cve_security_levels") + + result := _with_effective_on( + lib.result_helper_with_term(rego.metadata.chain(), [amount, level], level), + _configured_period[level].end, + ) + } + + some result in (enforced_results | leewayed_results) } # METADATA @@ -164,7 +190,7 @@ deny contains result if { # NOTE: unpatched vulnerabilities are defined as an optional attribute. The lack of them should # not be considered a violation nor a warning. See details in: # https://github.com/konflux-ci/architecture/blob/main/ADR/0030-tekton-results-naming-convention.md - not _vulnerabilities + not _vulnerabilities(_configured_period) result := lib.result_helper(rego.metadata.chain(), []) } @@ -207,47 +233,56 @@ _clair_report := report if { # maps vulnerabilities and reports the counts by category (patched/unpatched) # and severity -_clair_vulnerabilities[category] := vulns if { +_clair_vulnerabilities(period) := vulns if { reported_vulnerabilities := _clair_report.vulnerabilities - some category, vulnerabilities in { - "vulnerabilities": [v | - some v in reported_vulnerabilities - v.fixed_in_version != "" - ], - "unpatched_vulnerabilities": [v | - some v in reported_vulnerabilities - v.fixed_in_version = "" - ], - } + patched_vulnerabilities := [v | + some v in reported_vulnerabilities + v.fixed_in_version != "" + ] + + unpatched_vulnerabilities := [v | + some v in reported_vulnerabilities + v.fixed_in_version == "" + ] vulns := { - "critical": _count_by_severity_outside_leeway(vulnerabilities, "critical"), - "high": _count_by_severity_outside_leeway(vulnerabilities, "high"), - "medium": _count_by_severity_outside_leeway(vulnerabilities, "medium"), - "low": _count_by_severity_outside_leeway(vulnerabilities, "low"), - "unknown": _count_by_severity_outside_leeway(vulnerabilities, "unknown"), + "vulnerabilities": { + "critical": _count_by_severity_with_period(patched_vulnerabilities, "critical", period), + "high": _count_by_severity_with_period(patched_vulnerabilities, "high", period), + "medium": _count_by_severity_with_period(patched_vulnerabilities, "medium", period), + "low": _count_by_severity_with_period(patched_vulnerabilities, "low", period), + "unknown": _count_by_severity_with_period(patched_vulnerabilities, "unknown", period), + }, + "unpatched_vulnerabilities": { + "critical": _count_by_severity_with_period(unpatched_vulnerabilities, "critical", period), + "high": _count_by_severity_with_period(unpatched_vulnerabilities, "high", period), + "medium": _count_by_severity_with_period(unpatched_vulnerabilities, "medium", period), + "low": _count_by_severity_with_period(unpatched_vulnerabilities, "low", period), + "unknown": _count_by_severity_with_period(unpatched_vulnerabilities, "unknown", period), + }, } } # counts the vulnerabilities with the given severity excluding vulnerabilities -# within the leeway period -_count_by_severity_outside_leeway(vulnerabilities, severity) := count([v | +# within the given period +_count_by_severity_with_period(vulnerabilities, severity, period) := count([v | some v in vulnerabilities lower(v.normalized_severity) == severity - leeway_days := lib.rule_data("cve_leeway")[severity] - time.add_date(time.parse_rfc3339_ns(v.issued), 0, 0, leeway_days) < lib_time.effective_current_time_ns + p := period[severity] + time.parse_rfc3339_ns(v.issued) >= p.start + time.parse_rfc3339_ns(v.issued) < p.end ]) -_vulnerabilities := vulnerabilities if { - vulnerabilities := _clair_vulnerabilities.vulnerabilities +_vulnerabilities(period) := vulnerabilities if { + vulnerabilities := _clair_vulnerabilities(period).vulnerabilities } else := vulnerabilities if { some result in lib.results_named(_result_name) vulnerabilities := result.value.vulnerabilities } else := _vulnerabilities_deprecated -_unpatched_vulnerabilities := vulnerabilities if { - vulnerabilities := _clair_vulnerabilities.unpatched_vulnerabilities +_unpatched_vulnerabilities(period) := vulnerabilities if { + vulnerabilities := _clair_vulnerabilities(period).unpatched_vulnerabilities } else := vulnerabilities if { some result in lib.results_named(_result_name) vulnerabilities := result.value.unpatched_vulnerabilities @@ -271,16 +306,63 @@ _reports_result_name := "REPORTS" _report_oci_mime_type := "application/vnd.redhat.clair-report+json" -_non_zero_vulnerabilities(key) := _non_zero_levels(key, _vulnerabilities) +_non_zero_vulnerabilities(key, period) := _non_zero_levels(key, _vulnerabilities(period)) + +_leewayed_vulnerabilities(key) := {l1: amount | + some l1, amount_with_leeway in _count_vulnerabilities(key, _configured_period) + some l2, amount_without_leeway in _count_vulnerabilities(key, _zero_period) + l1 == l2 + amount := amount_without_leeway - amount_with_leeway + amount > 0 +} -_non_zero_unpatched(key) := _non_zero_levels(key, _unpatched_vulnerabilities) +_non_zero_unpatched(key, period) := _non_zero_levels(key, _unpatched_vulnerabilities(period)) -_non_zero_levels(key, vulnerabilities) := {level: amount | +_leewayed_unpatched_vulnerabilities(key) := {l1: amount | + some l1, amount_with_leeway in _count_unpatched_vulnerabilities(key, _configured_period) + some l2, amount_without_leeway in _count_unpatched_vulnerabilities(key, _zero_period) + l1 == l2 + amount := amount_without_leeway - amount_with_leeway + amount > 0 +} + +_count_vulnerabilities(key, period) := _count_levels(key, _vulnerabilities(period)) + +_count_unpatched_vulnerabilities(key, period) := _count_levels(key, _unpatched_vulnerabilities(period)) + +_count_levels(key, vulnerabilities) := {level: amount | some level in {a | some a in lib.rule_data(key)} amount := vulnerabilities[level] +} + +_non_zero_levels(key, vulnerabilities) := {level: amount | + some level, amount in _count_levels(key, vulnerabilities) amount > 0 } +_configured_period[severity] := period if { + leeway := lib.rule_data("cve_leeway") + + some severity in {"critical", "high", "medium", "low", "unknown"} + period := { + "start": 0, + "end": time.add_date(lib_time.effective_current_time_ns, 0, 0, leeway[severity] * -1), + } +} + +_zero_period[severity] := period if { + some severity in {"critical", "high", "medium", "low", "unknown"} + period := { + "start": 0, + "end": lib_time.effective_current_time_ns, + } +} + +_with_effective_on(result, effective_on) := object.union( + result, + {"effective_on": time.format([effective_on, "UTC", "2006-01-02T15:04:05Z07:00"])}, +) + _rule_data_errors contains msg if { keys := [ "restrict_cve_security_levels", diff --git a/policy/release/cve/cve_test.rego b/policy/release/cve/cve_test.rego index 0e99bb34..78ac86bf 100644 --- a/policy/release/cve/cve_test.rego +++ b/policy/release/cve/cve_test.rego @@ -572,7 +572,20 @@ test_clair_vulnerabilities if { }, } - got := cve._clair_vulnerabilities with cve._clair_report as _clair_report + p := { + "start": 0, + "end": lib_time.effective_current_time_ns, + } + + period := { + "critical": p, + "high": p, + "medium": p, + "low": p, + "unknown": p, + } + + got := cve._clair_vulnerabilities(period) with cve._clair_report as _clair_report lib.assert_equal(expected, got) } @@ -623,6 +636,7 @@ test_failure_with_full_report if { ), lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), ] + expected_deny := { { "code": "cve.cve_blockers", @@ -641,6 +655,25 @@ test_failure_with_full_report if { with input.attestations as attestations with ec.oci.image_manifest as _mock_image_manifest with ec.oci.blob as _mock_blob + + expected_warn := { + { + "code": "cve.unpatched_cve_warnings", + "term": "critical", + "msg": "Found 6 non-blocking unpatched CVE vulnerabilities of critical security level", + }, + { + "code": "cve.unpatched_cve_warnings", + "term": "high", + "msg": "Found 7 non-blocking unpatched CVE vulnerabilities of high security level", + }, + } + + # regal ignore:line-length + lib.assert_equal_results(cve.warn, expected_warn) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as _mock_blob } test_full_report_fetch_issue if { @@ -664,12 +697,10 @@ test_full_report_fetch_issue if { lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), ] - expected := { - { - "code": "cve.cve_results_found", - "msg": "Clair CVE scan results were not found" - } - } + expected := {{ + "code": "cve.cve_results_found", + "msg": "Clair CVE scan results were not found", + }} lib.assert_equal_results(cve.deny, expected) with input.image.ref as "registry.io/repository/image@sha256:image_digest" with input.attestations as attestations @@ -708,18 +739,50 @@ test_warning_leeway_with_full_report if { ), lib_test.mock_slsav1_attestation_with_tasks([tekton_test.slsav1_task_bundle(slsav1_task_with_result, _bundle)]), ] - expected_deny := {{ - "code": "cve.cve_blockers", - "term": "critical", - "msg": "Found 1 CVE vulnerabilities of critical security level", - }} + expected_deny := { + { + "code": "cve.cve_blockers", + "effective_on": lib_time.default_effective_on, + "msg": "Found 1 CVE vulnerabilities of critical security level", + "term": "critical", + }, + { + "code": "cve.cve_blockers", + "effective_on": "2022-03-26T00:00:00Z", # 2022-03-26 + 10 days = 2022-04-05 + "msg": "Found 2 CVE vulnerabilities of high security level", + "term": "high", + }, + { + "code": "cve.unpatched_cve_blockers", + "effective_on": lib_time.default_effective_on, + "msg": "Found 6 unpatched CVE vulnerabilities of critical security level", + "term": "critical", + }, + { + "code": "cve.unpatched_cve_blockers", + "effective_on": "2022-03-26T00:00:00Z", # 2022-03-26 + 10 days = 2022-04-05 + "msg": "Found 7 unpatched CVE vulnerabilities of high security level", + "term": "high", + }, + } # regal ignore:line-length - lib.assert_equal_results(cve.deny, expected_deny) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + lib.assert_equal_results_no_collections(cve.deny, expected_deny) with input.image.ref as "registry.io/repository/image@sha256:image_digest" + with input.attestations as attestations + with ec.oci.image_manifest as _mock_image_manifest + with ec.oci.blob as _mock_blob + with data.rule_data.cve_leeway as {"critical": 9, "high": 10} + with data.rule_data.restrict_unpatched_cve_security_levels as ["critical", "high"] + with data.rule_data.warn_unpatched_cve_security_levels as [] + with lib_time.effective_current_time_ns as time.parse_rfc3339_ns("2022-04-05T00:00:00Z") + + lib.assert_empty(cve.warn) with input.image.ref as "registry.io/repository/image@sha256:image_digest" with input.attestations as attestations with ec.oci.image_manifest as _mock_image_manifest with ec.oci.blob as _mock_blob with data.rule_data.cve_leeway as {"critical": 9, "high": 10} + with data.rule_data.restrict_unpatched_cve_security_levels as ["critical", "high"] + with data.rule_data.warn_unpatched_cve_security_levels as [] with lib_time.effective_current_time_ns as time.parse_rfc3339_ns("2022-04-05T00:00:00Z") } From b00d1c06af7806b943f1eef31056f4edc055df65 Mon Sep 17 00:00:00 2001 From: Zoran Regvart Date: Fri, 11 Oct 2024 12:54:19 +0200 Subject: [PATCH 3/3] Vulnerability leeway rule data violation Reference: https://issues.redhat.com/browse/EC-838 --- policy/release/cve/cve.rego | 24 +++++++++++++++++++ policy/release/cve/cve_test.rego | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/policy/release/cve/cve.rego b/policy/release/cve/cve.rego index 5b033828..652238dc 100644 --- a/policy/release/cve/cve.rego +++ b/policy/release/cve/cve.rego @@ -386,3 +386,27 @@ _rule_data_errors contains msg if { )[1] msg := sprintf("Rule data %s has unexpected format: %s", [key, violation.error]) } + +_rule_data_errors contains msg if { + value := lib.rule_data("cve_leeway") + leeway_days := { + "type": "integer", + "minimum": 0, + } + some violation in json.match_schema( + value, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "critical": leeway_days, + "high": leeway_days, + "medium": leeway_days, + "low": leeway_days, + "unknown": leeway_days, + }, + "additionalProperties": false, + }, + )[1] + msg := sprintf("Rule data cve_leeway has unexpected format: %s", [violation.error]) +} diff --git a/policy/release/cve/cve_test.rego b/policy/release/cve/cve_test.rego index 78ac86bf..facf2f67 100644 --- a/policy/release/cve/cve_test.rego +++ b/policy/release/cve/cve_test.rego @@ -786,6 +786,46 @@ test_warning_leeway_with_full_report if { with lib_time.effective_current_time_ns as time.parse_rfc3339_ns("2022-04-05T00:00:00Z") } +test_leeway_rule_data_check if { + d := {"cve_leeway": { + # wrong key + "blooper": 1, + # wrong type + "critical": "one", + # negative number + "high": -10, + # all good + "medium": 10, + }} + + expected := { + { + "code": "cve.rule_data_provided", + "msg": "Rule data cve_leeway has unexpected format: (Root): Additional property blooper is not allowed", + }, + { + "code": "cve.rule_data_provided", + "msg": "Rule data cve_leeway has unexpected format: critical: Invalid type. Expected: integer, given: string", + }, + { + "code": "cve.rule_data_provided", + "msg": "Rule data cve_leeway has unexpected format: high: Must be greater than or equal to 0", + }, + } + + attestations := [lib_test.att_mock_helper_ref( + cve._result_name, + { + "vulnerabilities": _dummy_counts_zero_high, + "unpatched_vulnerabilities": _dummy_counts_zero_high, + }, + "clair-scan", + _bundle, + )] + lib.assert_equal_results(cve.deny, expected) with input.attestations as attestations + with data.rule_data as d +} + _fingerprints(a, b) := [v | some n in numbers.range(a, b); v := sprintf("%d", [n])] _vulns(fingerprits, template) := {v |