Skip to content

Commit

Permalink
feat: add vulnerability report verifier (#1173)
Browse files Browse the repository at this point in the history
  • Loading branch information
akashsinghal authored Dec 1, 2023
1 parent 00f16de commit 60bc25f
Show file tree
Hide file tree
Showing 28 changed files with 7,884 additions and 15 deletions.
3 changes: 3 additions & 0 deletions .github/licenserc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ dependency:
- name: github.com/rcrowley/go-metrics # TODO: remove this when library is removed or under compatible license
version: v0.0.0-20201227073835-cf1acfcdf475
license: BSD-2-Clause
- name: github.com/owenrumney/go-sarif/v2 # TODO: remove this when library is under a compatible license
version: v2.3.0
license: Apache-2.0
1 change: 1 addition & 0 deletions .github/workflows/publish-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ jobs:
--build-arg build_sbom=true \
--build-arg build_licensechecker=true \
--build-arg build_schemavalidator=true \
--build-arg build_vulnerabilityreport=true \
--build-arg LDFLAGS="-X github.com/deislabs/ratify/internal/version.Version=$(TAG)" \
--label org.opencontainers.image.revision=${{ github.sha }} \
-t ${{ steps.prepare.outputs.ref }} \
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Follow the steps below to build and deploy a Ratify image with your private chan
export REGISTRY=yourregistry
docker buildx create --use

docker buildx build -f httpserver/Dockerfile --platform linux/amd64 --build-arg build_cosign=true --build-arg build_sbom=true --build-arg build_licensechecker=true --build-arg build_schemavalidator=true -t ${REGISTRY}/deislabs/ratify:yourtag .
docker buildx build -f httpserver/Dockerfile --platform linux/amd64 --build-arg build_cosign=true --build-arg build_sbom=true --build-arg build_licensechecker=true --build-arg build_schemavalidator=true --build-arg build_vulnerabilityreport=true -t ${REGISTRY}/deislabs/ratify:yourtag .
docker build --progress=plain --build-arg KUBE_VERSION="1.27.7" --build-arg TARGETOS="linux" --build-arg TARGETARCH="amd64" -f crd.Dockerfile -t ${REGISTRY}/localbuildcrd:yourtag ./charts/ratify/crds
```

Expand Down
30 changes: 27 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ SYFT_VERSION ?= v0.76.0
YQ_VERSION ?= v4.34.1
YQ_BINARY ?= yq_linux_amd64
ALPINE_IMAGE ?= alpine@sha256:93d5a28ff72d288d69b5997b8ba47396d2cbb62a72b5d87cd3351094b5d578a0
ALPINE_IMAGE_VULNERABLE ?= alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70
REDIS_IMAGE_TAG ?= 7.0-debian-11
CERT_ROTATION_ENABLED ?= false
REGO_POLICY_ENABLED ?= false
SBOM_TOOL_VERSION ?=v1.2.0
TRIVY_VERSION ?= 0.47.0

# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
ENVTEST_K8S_VERSION = 1.24.2
Expand Down Expand Up @@ -82,6 +84,7 @@ build-plugins:
go build -cover -coverpkg=github.com/deislabs/ratify/plugins/verifier/sample/... -o ./bin/plugins/ ./plugins/verifier/sample
go build -cover -coverpkg=github.com/deislabs/ratify/plugins/verifier/sbom/... -o ./bin/plugins/ ./plugins/verifier/sbom
go build -cover -coverpkg=github.com/deislabs/ratify/plugins/verifier/schemavalidator/... -o ./bin/plugins/ ./plugins/verifier/schemavalidator
go build -cover -coverpkg=github.com/deislabs/ratify/plugins/verifier/vulnerabilityreport/... -o ./bin/plugins/ ./plugins/verifier/vulnerabilityreport

.PHONY: install
install:
Expand Down Expand Up @@ -153,7 +156,7 @@ test-e2e: generate-rotation-certs
EXPIRING_CERT_DIR=.staging/rotation/expiring-certs CERT_DIR=.staging/rotation GATEKEEPER_VERSION=${GATEKEEPER_VERSION} bats -t ${BATS_PLUGIN_TESTS_FILE}

.PHONY: test-e2e-cli
test-e2e-cli: e2e-dependencies e2e-create-local-registry e2e-notation-setup e2e-notation-leaf-cert-setup e2e-cosign-setup e2e-licensechecker-setup e2e-sbom-setup e2e-schemavalidator-setup
test-e2e-cli: e2e-dependencies e2e-create-local-registry e2e-notation-setup e2e-notation-leaf-cert-setup e2e-cosign-setup e2e-licensechecker-setup e2e-sbom-setup e2e-schemavalidator-setup e2e-vulnerabilityreport-setup
rm ${GOCOVERDIR} -rf
mkdir ${GOCOVERDIR} -p
RATIFY_DIR=${INSTALL_DIR} TEST_REGISTRY=${TEST_REGISTRY} ${GITHUB_WORKSPACE}/bin/bats -t ${BATS_CLI_TESTS_FILE}
Expand Down Expand Up @@ -396,7 +399,7 @@ e2e-schemavalidator-setup:
mkdir -p .staging/schemavalidator

# Install Trivy
curl -L https://github.com/aquasecurity/trivy/releases/download/v0.35.0/trivy_0.35.0_Linux-64bit.tar.gz --output .staging/schemavalidator/trivy.tar.gz
curl -L https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz --output .staging/schemavalidator/trivy.tar.gz
tar -zxf .staging/schemavalidator/trivy.tar.gz -C .staging/schemavalidator

# Build/Push Images
Expand All @@ -415,6 +418,26 @@ e2e-schemavalidator-setup:
${TEST_REGISTRY}/all:v0 \
.staging/schemavalidator/trivy-scan.sarif:application/sarif+json

e2e-vulnerabilityreport-setup:
rm -rf .staging/vulnerabilityreport
mkdir -p .staging/vulnerabilityreport

# Install Trivy
curl -L https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz --output .staging/vulnerabilityreport/trivy.tar.gz
tar -zxf .staging/vulnerabilityreport/trivy.tar.gz -C .staging/vulnerabilityreport

# Build/Push Image
printf 'FROM ${ALPINE_IMAGE_VULNERABLE}\nCMD ["echo", "vulnerabilityreport image"]' > .staging/vulnerabilityreport/Dockerfile
docker build --no-cache -t ${TEST_REGISTRY}/vulnerabilityreport:v0 .staging/vulnerabilityreport
docker push ${TEST_REGISTRY}/vulnerabilityreport:v0

# Create/Attach Scan Result
.staging/vulnerabilityreport/trivy image --format sarif --output .staging/vulnerabilityreport/trivy-sarif.json ${TEST_REGISTRY}/vulnerabilityreport:v0
${GITHUB_WORKSPACE}/bin/oras attach \
--artifact-type application/sarif+json \
${TEST_REGISTRY}/vulnerabilityreport:v0 \
.staging/vulnerabilityreport/trivy-sarif.json:application/sarif+json

e2e-inlinecert-setup:
rm -rf .staging/inlinecert
mkdir -p .staging/inlinecert
Expand Down Expand Up @@ -468,14 +491,15 @@ e2e-deploy-base-ratify: e2e-notation-setup e2e-notation-leaf-cert-setup e2e-inli

rm mount_config.json

e2e-deploy-ratify: e2e-notation-setup e2e-notation-leaf-cert-setup e2e-cosign-setup e2e-cosign-setup e2e-licensechecker-setup e2e-sbom-setup e2e-schemavalidator-setup e2e-inlinecert-setup e2e-build-crd-image e2e-build-local-ratify-image e2e-helm-deploy-ratify
e2e-deploy-ratify: e2e-notation-setup e2e-notation-leaf-cert-setup e2e-cosign-setup e2e-cosign-setup e2e-licensechecker-setup e2e-sbom-setup e2e-schemavalidator-setup e2e-vulnerabilityreport-setup e2e-inlinecert-setup e2e-build-crd-image e2e-build-local-ratify-image e2e-helm-deploy-ratify

e2e-build-local-ratify-image:
docker build --progress=plain --no-cache \
--build-arg build_cosign=true \
--build-arg build_sbom=true \
--build-arg build_licensechecker=true \
--build-arg build_schemavalidator=true \
--build-arg build_vulnerabilityreport=true \
-f ./httpserver/Dockerfile \
-t localbuild:test .
kind load docker-image --name kind localbuild:test
Expand Down
14 changes: 14 additions & 0 deletions config/samples/config_v1beta1_verifier_vulnerabilityreport.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: config.ratify.deislabs.io/v1beta1
kind: Verifier
metadata:
name: verifier-vulnerabilityreport
spec:
name: vulnerabilityreport
artifactTypes: application/sarif+json
parameters:
maximumAge: 24h
disallowedSeverities:
- high
- critical
denylistCVEs:
- CVE-2021-44228 # Log4Shell
11 changes: 11 additions & 0 deletions config/samples/config_v1beta1_verifier_vulnerabilityreport2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: config.ratify.deislabs.io/v1beta1
kind: Verifier
metadata:
name: verifier-vulnerabilityreport
spec:
name: vulnerabilityreport
artifactTypes: application/sarif+json
parameters:
maximumAge: 24h
denylistCVEs:
- CVE-2021-44228 # Log4Shell
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ require (
github.com/open-policy-agent/opa v0.58.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0-rc5
github.com/owenrumney/go-sarif/v2 v2.3.0
github.com/pkg/errors v0.9.1
github.com/sigstore/cosign/v2 v2.2.1
github.com/sigstore/sigstore v1.7.5
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ github.com/aliyun/credentials-go v1.3.1 h1:uq/0v7kWrxmoLGpqjx7vtQ/s03f0zR//0br/x
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc=
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
Expand Down Expand Up @@ -640,6 +641,9 @@ github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/
github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U=
github.com/owenrumney/go-sarif/v2 v2.3.0 h1:wP5yEpI53zr0v5cBmagXzLbHZp9Oylyo3AJDpfLBITs=
github.com/owenrumney/go-sarif/v2 v2.3.0/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
Expand Down Expand Up @@ -769,6 +773,8 @@ github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinC
github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
github.com/veraison/go-cose v1.2.0 h1:Ok0Hr3GMAf8K/1NB4sV65QGgCiukG1w1QD+H5tmt0Ow=
github.com/veraison/go-cose v1.2.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4=
github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/go-gitlab v0.93.2 h1:kNNf3BYNYn/Zkig0B89fma12l36VLcYSGu7OnaRlRDg=
Expand Down Expand Up @@ -804,6 +810,7 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zalando/go-keyring v0.2.2 h1:f0xmpYiSrHtSNAVgwip93Cg8tuF45HJM6rHq/A5RI/4=
github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg=
go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng=
go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8=
Expand Down
2 changes: 2 additions & 0 deletions httpserver/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ARG build_sbom
ARG build_cosign
ARG build_licensechecker
ARG build_schemavalidator
ARG build_vulnerabilityreport

ENV CGO_ENABLED=0 \
GOOS=${TARGETOS} \
Expand All @@ -43,6 +44,7 @@ RUN if [ "$build_sbom" = "true" ]; then go build -o /app/out/plugins/ /app/plugi
RUN if [ "$build_cosign" = "true" ]; then go build -o /app/out/plugins/ /app/plugins/verifier/cosign; fi
RUN if [ "$build_licensechecker" = "true" ]; then go build -o /app/out/plugins/ /app/plugins/verifier/licensechecker; fi
RUN if [ "$build_schemavalidator" = "true" ]; then go build -o /app/out/plugins/ /app/plugins/verifier/schemavalidator; fi
RUN if [ "$build_vulnerabilityreport" = "true" ]; then go build -o /app/out/plugins/ /app/plugins/verifier/vulnerabilityreport; fi

FROM $BASEIMAGE
LABEL org.opencontainers.image.source https://github.com/deislabs/ratify
Expand Down
11 changes: 11 additions & 0 deletions library/notation-nested-validation/samples/constraint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: NotationNestedValidation
metadata:
name: notation-nested-constraint
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces: ["default"]
88 changes: 88 additions & 0 deletions library/notation-nested-validation/template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
name: notationnestedvalidation
spec:
crd:
spec:
names:
kind: NotationNestedValidation
validation:
openAPIV3Schema:
type: object
properties:
issuer:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package notationnestedvalidation
# This template defines policy for notation nested validation.
# It checks the following:
# - If there are any system errors
# - If there are errors for any of the images
# - Each image has a valid notary project signature
# - Each nested artifact has a valid notary project signature
import future.keywords.if
remote_data := response {
images := [img | img = input.review.object.spec.containers[_].image]
images_init := [img | img = input.review.object.spec.initContainers[_].image]
images_ephemeral := [img | img = input.review.object.spec.ephemeralContainers[_].image]
other_images := array.concat(images_init, images_ephemeral)
all_images := array.concat(other_images, images)
response := external_data({"provider": "ratify-provider", "keys": all_images})
}
violation[{"msg": msg}] {
general_violation[{"result": msg}]
}
# Check if there are any system errors
general_violation[{"result": result}] {
err := remote_data.system_error
err != ""
result := sprintf("System error calling external data provider: %s", [err])
}
# Check if there are errors for any of the images
general_violation[{"result": result}] {
count(remote_data.errors) > 0
result := sprintf("Error validating one or more images: %s", remote_data.errors)
}
# Check if the success criteria is true
general_violation[{"result": result}] {
subject_validation := remote_data.responses[_]
subject_result := subject_validation[1]
failed_verify(subject_result)
result := sprintf("Subject failed verification: %s", [subject_validation[0]])
}
failed_verify(reports) if {
newReports := {"nestedResults": reports.verifierReports}
has_subject_failed_verify(newReports)
}
has_subject_failed_verify(nestedReports) if {
[path, value] := walk(nestedReports)
path[count(path) - 1] == "nestedResults"
not notary_project_signature_pass_verify(value)
}
notary_project_signature_pass_verify(nestedReports) if {
count_with_success := notary_project_signature_count(nestedReports)
count_with_success > 0
}
notary_project_signature_count(nestedReports) := number if {
sigs := [x |
some i
nestedReports[i].isSuccess == true
nestedReports[i].artifactType == "application/vnd.cncf.notary.signature"
x := nestedReports[i].subject
]
number := count(sigs)
}
3 changes: 3 additions & 0 deletions library/rego/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Ratify Rego Policies

This folder contains `.rego` files that contain rego policies to be used ONLY with Ratify's [Rego Policy Provider](https://ratify.dev/docs/1.0/reference/crds/policies#regopolicy)
70 changes: 70 additions & 0 deletions library/rego/vulnerability-report-validation.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright The Ratify Authors.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

# http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

package ratify.policy

import future.keywords.if
import future.keywords.in
import future.keywords.every

# This template defines policy for vulnerability report validation.
# It checks the following:
# - If there are any system errors
# - If there are errors for any of the images
# - There is at least one vulnerability report that was verified
# - Only considers the latest vulnerability report
# - The latest vulnerability report is valid (isSuccess = true)
# - The latest vulnerability report has a valid notary project signature (if require_signature = true)

default require_signature := false # change to true to require notary project signature on vulnerability report
default valid := false

# all artifacts MUST be valid
valid {
not failed_verify(input)
}

failed_verify(reports) {
not process_vuln_reports(reports)
}

process_vuln_reports(subject_result) if {
# collect verifier reports from vulnerabilityreport verifier
vuln_results := [res | subject_result.verifierReports[i].verifierReports[j].name == "vulnerabilityreport"; res := subject_result.verifierReports[i].verifierReports[j]]
count(vuln_results) > 0
# calculate the timestamp between current time and creation time
timestamp_diff_results_map := {diff_in_ns: i | diff_in_ns := time.now_ns() - time.parse_rfc3339_ns(vuln_results[i].extensions["createdAt"])}
count(timestamp_diff_results_map) > 0
# extract time difference durations into separate array to find global minimum
timestamp_diff_results_arr := [key | timestamp_diff_results_map[key]]
smallest_timestamp_diff := min(timestamp_diff_results_arr)
# validate latest report
process_vuln_report(vuln_results[timestamp_diff_results_map[smallest_timestamp_diff]])
}

process_vuln_report(report) if {
report.isSuccess == true
valid_signatures(report)
}

valid_signatures(_) := true {
require_signature == false
}

valid_signatures(report) := true {
require_signature
count(report.nestedResults) > 0
some nestedResult in report.nestedResults
nestedResult.artifactType == "application/vnd.cncf.notary.signature"
nestedResult.isSuccess
}
11 changes: 11 additions & 0 deletions library/vulnerability-report-validation/samples/constraint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: VulnerabilityReportValidation
metadata:
name: vulnerability-report-validation-constraint
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces: ["default"]
Loading

0 comments on commit 60bc25f

Please sign in to comment.