├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .prettierrc.js ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── config ├── config.toml └── secrets.production.ejson ├── entrypoint.sh ├── testdata ├── config.toml ├── key │ ├── cb0849626842a90427a026dc78ab39f8ded6f3180477c848ed3fe7c9c85da93d │ └── d27ea2243e4b454b5e5b1b34e69fbc04aebdf9e8e44e6199886d29677ec7a551 ├── test.ejson ├── test.sops.json ├── test_repo.ejson └── testkey.asc ├── tutorials └── cloudrun │ ├── Dockerfile-server │ ├── cloudbuild-server.yaml │ ├── config.toml.template │ └── examples │ ├── Dockerfile.bad │ ├── Dockerfile.good │ ├── cloudbuild-bad.yaml │ └── cloudbuild-good.yaml └── v2 ├── attestation.go ├── attestation ├── payload.go └── payload_test.go ├── auth.go ├── auth ├── connections.go ├── error.go └── google │ └── auth.go ├── authorizedcheck.go ├── check.go ├── checks ├── README.md ├── approved │ ├── check.go │ └── check_test.go ├── diy │ ├── check.go │ └── check_test.go ├── nobody │ ├── check.go │ └── check_test.go ├── org │ ├── check.go │ └── check_test.go ├── provenance │ ├── check.go │ └── check_test.go └── snakeoil │ ├── check.go │ └── check_test.go ├── client ├── check.go ├── client.go ├── client_test.go ├── defaultidtoken.go └── verify.go ├── cmd ├── config │ ├── auth.go │ ├── checks.go │ ├── cloudrun.go │ ├── config.go │ ├── get_orgs_config.go │ ├── get_orgs_config_test.go │ ├── get_required_checks.go │ ├── get_required_checks_test.go │ ├── kms.go │ ├── metadataclient.go │ ├── metrics.go │ ├── metrics_test.go │ ├── register.go │ ├── repoclient.go │ ├── repoclient_test.go │ ├── scanner.go │ ├── secrets.go │ ├── secrets_test.go │ └── validrepos.go ├── voucher_client │ ├── README.md │ ├── config.go │ ├── digest.go │ ├── lookup.go │ ├── main.go │ ├── output.go │ ├── root.go │ ├── submit.go │ └── verify.go ├── voucher_server │ ├── README.md │ ├── TUTORIAL.md │ ├── main.go │ └── server.go └── voucher_subscriber │ ├── README.md │ ├── main.go │ └── subscriber.go ├── containeranalysis ├── attestation.go ├── attestation_test.go ├── build.go ├── client.go ├── containeranalysis.go ├── containeranalysis_test.go ├── create.go ├── error.go ├── poll.go ├── types.go └── vulnerability.go ├── docker ├── call.go ├── config.go ├── config_test.go ├── digest.go ├── digest_test.go ├── docker_error.go ├── imageconfig.go ├── manifest.go ├── manifest_request.go ├── manifest_test.go ├── ocischema │ ├── config.go │ ├── config_test.go │ ├── manifest.go │ └── manifest_test.go ├── schema1 │ ├── config.go │ ├── config_test.go │ ├── manifest.go │ └── manifest_test.go ├── schema2 │ ├── config.go │ ├── config_test.go │ ├── manifest.go │ └── manifest_test.go └── uri │ ├── project.go │ ├── project_test.go │ ├── uri.go │ └── uri_test.go ├── go.mod ├── go.sum ├── grafeas ├── client.go ├── client_test.go ├── errors.go ├── grafeas_api_error.go ├── grafeas_service.go ├── grafeas_service_test.go ├── mocks │ └── grafeas_service_mock.go ├── objects │ ├── attestation.go │ ├── build.go │ ├── discovery.go │ ├── exchange.go │ ├── note.go │ ├── occurrence.go │ ├── package.go │ └── vulnerability.go ├── poll.go └── types.go ├── imagedata.go ├── imagedata_test.go ├── interface.go ├── metadatacheck.go ├── metadataclient.go ├── metadatatype.go ├── metrics ├── datadog.go ├── datadog_test.go ├── metrics.go ├── noop_client.go ├── otel.go ├── otel_test.go └── statsd.go ├── mock_check.go ├── mock_metadataclient.go ├── provenancecheck.go ├── register.go ├── register_test.go ├── repository ├── README.md ├── auth.go ├── build_artifact.go ├── build_detail.go ├── client.go ├── config.go ├── consts.go ├── errors.go ├── github │ ├── branch_protections_query.go │ ├── branch_protections_result.go │ ├── branch_protections_test.go │ ├── branch_query.go │ ├── branch_result.go │ ├── branch_test.go │ ├── client.go │ ├── client_test.go │ ├── commit_info_query.go │ ├── commit_info_result.go │ ├── commit_info_test.go │ ├── consts.go │ ├── default_branch_query.go │ ├── default_branch_result.go │ ├── default_branch_test.go │ ├── github_utils.go │ ├── github_utils_test.go │ ├── pull_request_reviews_query.go │ ├── pull_request_reviews_result.go │ ├── pull_request_reviews_test.go │ ├── queries.go │ ├── queries_test.go │ ├── repository_org_info_query.go │ ├── repository_org_info_result.go │ ├── repository_org_info_test.go │ ├── roundtripper.go │ ├── roundtripper_test.go │ ├── utils.go │ └── utils_test.go ├── mock_client.go ├── objects.go └── objects_test.go ├── repositorycheck.go ├── request.go ├── response.go ├── response_test.go ├── result.go ├── scanner.go ├── server ├── README.md ├── authentication.go ├── check.go ├── config.go ├── handlers.go ├── input.go ├── isenabled.go ├── log.go ├── routes.go ├── server.go ├── server_test.go └── verify.go ├── severity.go ├── severity_test.go ├── signer ├── errors.go ├── kms │ ├── signer.go │ └── signer_test.go ├── pgp │ ├── keyring.go │ ├── keyring_test.go │ └── sign.go └── signer.go ├── subscriber ├── check.go ├── config.go ├── payload.go ├── payload_test.go └── subscriber.go ├── suite.go ├── suite_test.go ├── testing ├── auth.go ├── docker.go ├── manifests.go ├── prepare_docker.go ├── reference.go ├── scanner.go ├── server.go ├── signer.go └── transport.go ├── validrepocheck.go ├── vulnerability.go ├── vulnerability_error.go ├── vulnerability_scanner.go ├── vulnerability_test.go └── vulnerabilitycheck.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: [ 'v*.*.*' ] 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.19 23 | - name: Extract release changelog 24 | run: | 25 | version=${GITHUB_REF#refs/tags/v*} 26 | mkdir -p tmp 27 | sed '/^# '$version'/,/^# /!d;//d;/^\s*$/d' CHANGELOG.md > tmp/release_changelog.md 28 | - name: Release 29 | uses: goreleaser/goreleaser-action@5df302e5e9e4c66310a6b6493a8865b12c555af2 30 | with: 31 | distribution: goreleaser 32 | version: v1.2.1 33 | args: release --rm-dist --release-notes=tmp/release_changelog.md 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up Go 1.x 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: 1.19 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v2 18 | 19 | - name: Test 20 | run: make test 21 | 22 | - name: Lint 23 | uses: golangci/golangci-lint-action@5c56cd6c9dc07901af25baab6f2b0d9f3b7c3018 # v2.5.2 24 | with: 25 | working-directory: v2/ 26 | args: --timeout 3m 27 | skip-pkg-cache: true 28 | skip-build-cache: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # voucher specific output 15 | coverage.txt 16 | 17 | # build output 18 | dist 19 | build 20 | /tmp -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: voucher 2 | before: 3 | hooks: 4 | - make ensure-deps 5 | builds: 6 | - id: voucher_server 7 | dir: v2 8 | main: ./cmd/voucher_server 9 | binary: voucher_server 10 | env: 11 | - CGO_ENABLED=0 12 | - id: voucher_subscriber 13 | dir: v2 14 | main: ./cmd/voucher_subscriber 15 | binary: voucher_subscriber 16 | env: 17 | - CGO_ENABLED=0 18 | - id: voucher_client 19 | dir: v2 20 | main: ./cmd/voucher_client 21 | binary: voucher_client 22 | env: 23 | - CGO_ENABLED=0 24 | archives: 25 | - id: voucher_server 26 | builds: 27 | - voucher_server 28 | name_template: "voucher_server_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 29 | files: 30 | - LICENSE 31 | wrap_in_directory: true 32 | replacements: 33 | darwin: Darwin 34 | linux: Linux 35 | windows: Windows 36 | amd64: x86_64 37 | - id: voucher_subscriber 38 | builds: 39 | - voucher_subscriber 40 | name_template: "voucher_subscriber_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 41 | files: 42 | - LICENSE 43 | wrap_in_directory: true 44 | replacements: 45 | darwin: Darwin 46 | linux: Linux 47 | windows: Windows 48 | amd64: x86_64 49 | - id: voucher_client 50 | builds: 51 | - voucher_client 52 | name_template: "voucher_client_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 53 | files: 54 | - LICENSE 55 | wrap_in_directory: true 56 | replacements: 57 | darwin: Darwin 58 | linux: Linux 59 | windows: Windows 60 | amd64: x86_64 61 | checksum: 62 | name_template: 'checksums.txt' 63 | snapshot: 64 | name_template: "{{ .Tag }}-next" 65 | changelog: 66 | sort: asc 67 | filters: 68 | exclude: 69 | - '^docs:' 70 | - '^test:' 71 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/, for embedded configuration files 2 | module.exports = { 3 | useTabs: true 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | * Nothing! 4 | 5 | # 2.7.0 6 | 7 | * Support for authentication when Voucher is hosted as a Google CloudRun service (#45, #48, #49, #53, #54, #57) 8 | * Client send `User-Agent` header when making HTTP requests (#52) 9 | * Server avoid leaking `containeranalysis.Client` (#50) 10 | 11 | # 2.6.2 12 | 13 | * Introduce bodyclose linter (#46) 14 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Voucher contributors 2 | ============================================ 3 | 4 | ## From Shopify 5 | * **[Catherine Jones](https://github.com/catherinejones)** 6 | * **[Owen Craston](https://github.com/owencraston)** 7 | * **[Felix Glaser](https://github.com/klautcomputing)** 8 | * **[Fedor Lisovskiy](https://github.com/fedorlis)** 9 | 10 | ## No one else yet :) 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Builder 2 | FROM golang:1.19-alpine as builder 3 | 4 | LABEL maintainer "catherinejones" 5 | WORKDIR /go/src/github.com/grafeas/voucher 6 | RUN apk --no-cache add \ 7 | git \ 8 | make 9 | COPY Makefile . 10 | COPY v2/go.mod v2/ 11 | COPY v2/go.sum v2/ 12 | RUN make ensure-deps 13 | COPY . . 14 | RUN make voucher_server 15 | 16 | # Final build 17 | FROM alpine:3.16 18 | 19 | COPY --from=builder /go/src/github.com/grafeas/voucher/build/voucher_server /usr/local/bin/voucher_server 20 | COPY --from=builder /go/src/github.com/grafeas/voucher/entrypoint.sh /usr/local/entrypoint.sh 21 | COPY --from=builder /go/src/github.com/grafeas/voucher/config/config.toml /etc/voucher/config.toml 22 | COPY config/secrets.production.ejson /etc/voucher/secrets.production.ejson 23 | 24 | RUN apk add --no-cache \ 25 | ca-certificates && \ 26 | addgroup -S -g 10000 voucher && \ 27 | adduser -S -u 10000 -G voucher voucher 28 | 29 | USER 10000:10000 30 | 31 | ENTRYPOINT ["/usr/local/entrypoint.sh"] 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Go parameters 2 | GOCMD=cd v2 && go 3 | GORELEASER=goreleaser 4 | GOLANGCI-LINT=cd v2 && golangci-lint 5 | DOCKER=docker 6 | GOPATH?=`echo $$GOPATH` 7 | GOBUILD=$(GOCMD) build 8 | GOCLEAN=$(GOCMD) clean 9 | PACKAGES := voucher_server voucher_subscriber voucher_client 10 | CODE=./cmd/ 11 | SERVER_NAME=voucher_server 12 | SUBSCRIBER_NAME=voucher_subscriber 13 | CLIENT_NAME=voucher_client 14 | IMAGE_NAME?=voucher 15 | 16 | export GO111MODULE=on 17 | 18 | .PHONY: clean ensure-deps update-deps system-deps \ 19 | test show-coverage \ 20 | build release snapshot container mocks \ 21 | $(PACKAGES) 22 | 23 | all: clean ensure-deps build 24 | 25 | # System Dependencies 26 | system-deps: 27 | ifeq ($(shell $(GOCMD) version 2> /dev/null) , "") 28 | $(error "go is not installed") 29 | endif 30 | ifeq ($(shell $(DOCKER) -v dot 2> /dev/null) , "") 31 | $(error "docker is not installed") 32 | endif 33 | ifeq ($(shell $(GOLANGCI-LINT) version 2> /dev/null) , "") 34 | $(error "golangci-lint is not installed") 35 | endif 36 | ifeq ($(shell $(GORELEASER) --version dot 2> /dev/null) , "") 37 | $(error "goreleaser is not installed") 38 | endif 39 | $(info "No missing dependencies") 40 | 41 | show-coverage: test 42 | $(GOCMD) tool cover -html=coverage.txt 43 | 44 | test: 45 | $(GOCMD) test ./... -race -coverprofile=coverage.txt -covermode=atomic 46 | 47 | lint: 48 | $(GOLANGCI-LINT) run 49 | 50 | lint-new: 51 | $(GOLANGCI-LINT) run --new-from-rev main 52 | 53 | clean: 54 | $(GOCLEAN) 55 | @for PACKAGE in $(PACKAGES); do \ 56 | rm -vrf build/$$PACKAGE; \ 57 | done 58 | 59 | ensure-deps: 60 | $(GOCMD) mod download 61 | $(GOCMD) mod verify 62 | 63 | update-deps: 64 | $(GOCMD) get -u -t all 65 | $(GOCMD) mod tidy 66 | 67 | build: $(PACKAGES) 68 | 69 | voucher_client: 70 | $(GOBUILD) -o ../build/$(CLIENT_NAME) -v $(CODE)$(CLIENT_NAME) 71 | 72 | voucher_subscriber: 73 | $(GOBUILD) -o ../build/$(SUBSCRIBER_NAME) -v $(CODE)$(SUBSCRIBER_NAME) 74 | 75 | voucher_server: 76 | $(GOBUILD) -o ../build/$(SERVER_NAME) -v $(CODE)$(SERVER_NAME) 77 | 78 | container: 79 | $(DOCKER) build -t $(IMAGE_NAME) . 80 | 81 | release: 82 | $(GORELEASER) 83 | 84 | snapshot: 85 | $(GORELEASER) --snapshot 86 | 87 | mocks: 88 | mockgen -source=grafeas/grafeas_service.go -destination=grafeas/mocks/grafeas_service_mock.go package=mocks 89 | 90 | test-in-docker: 91 | docker run -v $(PWD):/go/src/github.com/grafeas/voucher -w /go/src/github.com/grafeas/voucher -e CGO_ENABLED=0 -it golang:1.15.6-alpine go test ./... 92 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | To report a security issue, please email [grafeas-security-report](mailto:grafeas-security-report@google.com) 2 | with a description of the issue, the steps you took to create the issue, 3 | affected versions, and if known, mitigations for the issue. Our vulnerability 4 | management team will acknowledge receiving your email within 3 working days. 5 | This project follows a 90 day disclosure timeline. 6 | -------------------------------------------------------------------------------- /config/config.toml: -------------------------------------------------------------------------------- 1 | dryrun = false 2 | scanner = "metadata" 3 | failon = "high" 4 | metadata_client = "containeranalysis" 5 | 6 | binauth_project = "your-project-here" 7 | signer = "kms" 8 | valid_repos = [ 9 | "gcr.io/path/to/my/project", 10 | ] 11 | 12 | trusted_builder_identities = [ 13 | "email@example.com", 14 | "idcloudbuild.gserviceaccount.com" 15 | ] 16 | 17 | trusted_projects = [ 18 | "trusted-builds" 19 | ] 20 | 21 | [checks] 22 | diy = true 23 | nobody = true 24 | provenance = true 25 | snakeoil = true 26 | 27 | [server] 28 | port = 8000 29 | require_auth = true 30 | username = "username here" 31 | password = "bcrypt hash of your password" 32 | 33 | [ejson] 34 | dir = "/key" 35 | secrets = "/etc/voucher/secrets.production.ejson" 36 | 37 | [metrics] 38 | backend = "statsd" 39 | # OR: backend = "datadog" 40 | # OR: backend = "opentelemetry" 41 | tags = [] 42 | 43 | # for statsd metrics backend 44 | [statsd] 45 | addr = "localhost:8125" 46 | sample_rate = 0.1 47 | 48 | # for opentelemetry backend 49 | [opentelemetry] 50 | addr = "grpc://localhost:4317" 51 | insecure = true 52 | 53 | [repository.shopify] 54 | org-url = "https://github.com/Shopify" 55 | 56 | [repository.grafeas] 57 | org-url = "https://github.com/grafeas" 58 | 59 | [[kms_keys]] 60 | check = "diy" 61 | path = "projects//locations/global/keyRings/-keys/cryptoKeys//cryptoKeyVersions/" 62 | algo = "SHA512" 63 | 64 | [[kms_keys]] 65 | check = "snakeoil" 66 | path = "projects//locations/global/keyRings/-keys/cryptoKeys//cryptoKeyVersions/" 67 | algo = "SHA512" 68 | 69 | [grafeasos] 70 | hostname = "" 71 | version = "" 72 | vuln_project = "" 73 | -------------------------------------------------------------------------------- /config/secrets.production.ejson: -------------------------------------------------------------------------------- 1 | { 2 | "_public_key": "your pubkey goes here", 3 | "openpgpkeys": { 4 | "diy": "the pgp private key goes here", 5 | "nobody": "the pgp private key goes here", 6 | "provenance": "the pgp private key goes here", 7 | "snakeoil": "the pgp private key goes here", 8 | "organization-name": "the pgp private key goes here", 9 | "organization2-name": "the pgp private key goes here" 10 | }, 11 | "datadog": { 12 | "api_key": "the datadog api key goes here", 13 | "app_key": "the datadog app key goes here" 14 | }, 15 | "repositories": { 16 | "organization-name": "token auth type", 17 | "organization2-name": { 18 | "username": "username", 19 | "password": "password" 20 | }, 21 | "organization3-name": { 22 | "_app_id": "Github App ID", 23 | "_installation_id": "Github Installation ID", 24 | "private_key": "Github App Private Key" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # TODO for end users: 3 | # 1. set project 4 | # 2 activate service account 5 | 6 | voucher_server -c /etc/voucher/config.toml 7 | -------------------------------------------------------------------------------- /testdata/config.toml: -------------------------------------------------------------------------------- 1 | dryrun = true 2 | scanner = "metadata" 3 | failon = "high" 4 | binauth_project = "voucher-binauth" 5 | metadata_client = "grafeasos" 6 | signer = "pgp" 7 | 8 | [checks] 9 | diy = true 10 | nobody = true 11 | provenance = true 12 | snakeoil = true 13 | 14 | [server] 15 | port = 8000 16 | timeout = 240 17 | require_auth = true 18 | username = "vouchertester" 19 | password = "$2a$10$.PaOjV8GdqSHSmUtfolsJeF6LsAq/3CNsFCYGb3IoN/mO9xj1c/yG" 20 | 21 | [ejson] 22 | dir = "../../testdata/key" 23 | secrets = "../../testdata/test.ejson" 24 | 25 | [repository.shopify] 26 | org-url = "https://github.com/Shopify" 27 | 28 | [required.env1] 29 | diy = true 30 | provenance = false 31 | 32 | [required.env2] 33 | diy = true 34 | nobody = true 35 | -------------------------------------------------------------------------------- /testdata/key/cb0849626842a90427a026dc78ab39f8ded6f3180477c848ed3fe7c9c85da93d: -------------------------------------------------------------------------------- 1 | aaad0d16efa2c168a7b198315400788b1295393567b0944240ec81730c614ae5 -------------------------------------------------------------------------------- /testdata/key/d27ea2243e4b454b5e5b1b34e69fbc04aebdf9e8e44e6199886d29677ec7a551: -------------------------------------------------------------------------------- 1 | 8bb0c5cbc5ce883201e7c2270ae357336429fec7c8397ab8aab74da1b83ea7b7 2 | -------------------------------------------------------------------------------- /testdata/test.sops.json: -------------------------------------------------------------------------------- 1 | { 2 | "openpgpkeys": { 3 | "snakeoil": "ENC[AES256_GCM,data:dSwVlA==,iv:SqgLfrwjvBwUl0+IZgSo2xkihshUby0NjhZ25ovJ9/k=,tag:xFTjoH82+rEk6RPDWo99ig==,type:str]" 4 | }, 5 | "repositories": { 6 | "organization-name": { 7 | "token": "ENC[AES256_GCM,data:cOfWrtqcFfUxMTVBN/XwkQxw1Tfv,iv:ZWR2+haVvIrgvcer2cE1SWmo7BWrwzdPmxAGHovi7+I=,tag:qIZqahCuSLz0JjIJp6fCsw==,type:str]" 8 | } 9 | }, 10 | "sops": { 11 | "kms": null, 12 | "gcp_kms": null, 13 | "azure_kv": null, 14 | "hc_vault": null, 15 | "age": null, 16 | "lastmodified": "2022-10-11T16:39:01Z", 17 | "mac": "ENC[AES256_GCM,data:TRf6c/wwBLSLXXZIMc4vLoVEpImb4z/hf3e+uqdfjA9aka0DvbJtEclKhXvq9QAzcJfZBdWteifEeCJFjhqobydaya6tbMIdT66q++uo9+xnxTLx/1/4Gld/+wbm/imGfly5LQz5pcbOgqTRhE578r0q3PF39o+Ws9BIXj1jhME=,iv:geRiEKe5S28yZnXca7KabIAyprLJ77q/wGORb9RFuys=,tag:lnNoQEe2mAotDvuOU05rag==,type:str]", 18 | "pgp": [ 19 | { 20 | "created_at": "2021-08-27T17:14:55Z", 21 | "enc": "-----BEGIN PGP MESSAGE-----\n\nhQEMAx6S4rS7c+iFAQf/bHMnBjjdMNllrfwmjXALZqhB9gs7ZFk6/3JsRufcmBy4\nCs0bdEB+FTExLeYH6PTgnlYj8Uaw8MOHJYUQf+c2rJJWiGTh8ZVCyAz0gFRLWIHS\nC9e2DqpnjYJmZgcqfTY6RYiM8dIfEPlyQPYgaCLdcxv6yTxvTeKFeGOnkB4k060o\n3EvsxfqjcjcI9z5sTpQOuvCQQHSEFdvliifVSaY053UdMCHjGfKk5zdFOsQTssAW\n1rg3sajFqgTf9KSzCSqsrNylPoTN6HHa+kaqXvLAXe0DzGXf6IGyPsYN+YDfI9FY\nc2ZwZJlHHIHGC7eA5cMcSFJRTVU/g0nI7BYMFgOu39JcARo1rxzWkSiX3ISKUKu4\n9iDQ2iZChWtL/mVLQzAmtpBS4wKHJnhivyzmRva6VITpxIc/vR1TXqUP5gZOIKF4\nO5QEyvTGdhKcERX6Jnkx5pPmubrkyvo7uaXbRz0=\n=5Qdi\n-----END PGP MESSAGE-----\n", 22 | "fp": "90E942641C07A4C466BA97161E92E2B4BB73E885" 23 | } 24 | ], 25 | "unencrypted_suffix": "_unencrypted", 26 | "version": "3.7.3" 27 | } 28 | } -------------------------------------------------------------------------------- /testdata/test_repo.ejson: -------------------------------------------------------------------------------- 1 | { 2 | "_public_key": "cb0849626842a90427a026dc78ab39f8ded6f3180477c848ed3fe7c9c85da93d", 3 | "repositories": { 4 | "shopify": { 5 | "token": "EJ[1:vt978NEKynsJhZlCvg5XdSE2S2PM1JBPK0tlC05cQAc=:4mDZCykfieedtHGoM0UT+Wr6zPO9J6XO:/AuGJ3I2QVnk52qOLo0sQ+EzEAk=]" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /testdata/testkey.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PRIVATE KEY BLOCK----- 2 | 3 | lQOYBFtNFlIBCADKmNXW6u3gYKnKNXXcWBRBaVJLgkvTOGhEJfcr4u/UEyk4mSen 4 | 9KXV29RDRhT24ziOTi6kLXZc4Lzte0uKJaMgR1krnPmcpzQH76qT9tmva9VMZNU6 5 | S6z7QAjl9ASn+FW0cQBo4ohPIho9NDs7DhxqHjc8yx3rnu2qNXP7sygjNX9qqnyt 6 | 1N/2Ld8wfCgWYR7NbU2Z2FTRuD3LlygJ/AaIaBtKYZCgRAHpl3Mc/r8+7oASaJWw 7 | MfwgVbek1AZQ8/Xk+RPDH7oQr+GeQAkUOlfEPPC2X83hnN5RXywJ2u1BBvi/EFcb 8 | poH89zlGoO7h3ejlw3AQlIdYgA31p86usbpDABEBAAEAB/0R8GSG6jhz9Ls0D3XH 9 | M/lfLV8/FmN2aXk6B46SUT7hLW0p+M29HnmMrTFnX449qjL4zs1sdiYT5UZ1VMSE 10 | j/6YvhiUNwsXJusBhOQ6w9HUqZyybf8/cTH48VuYWPoMkX2tQ5BAuUZOk3t1Tems 11 | ufwkHVbQyD92/JSYzLDfaaa1L0Al5FYtfYUlwPUOBTfkRXkJsEBd+3xDCdBbBm0g 12 | QDHGMpX3xxaY6Oa2lGb7oHcCqXsjD4PW7CHz3nNQRgMU7I/BbndopvsWmp1LZD43 13 | XnpM1KTfr1i9gDFteTtdQavVCVZQPWydTnEGISA+3sVCGaDltB1rtWRFkkHDxi93 14 | CpFZBADcfpDAfAVTPzDLr649EQmM5xQMzWTsAG2ArL90Vs/xyhSetRf2yvemlEA5 15 | gJhavpBkHWrtol3VkvnNRUxifnAkVDkYpu+bb5qkvwdYScyQ1wKJ4AmApMSu1r2n 16 | 9AvQfLzeZMW+zzaYsisB55wR8tWdPc1x1JJBtcAsxzgmIVtmfQQA6zh8jMdJPDFQ 17 | rsoRSSQTAFtfjyMC3GuPJNebNqXV1brTPJmnBSpUiOIQFi9qd5kBm9Kj+Y84NY0e 18 | 5XHHdXXM+cEts8VO4RtiFz3THFxJcNgwDDXWLDPCe+avEQLtG1Ln+bnmgQ/SDYeU 19 | 7Gt1cGqPSbNMRtsNWITFpNSet/t9v78EAJ9pbEGzQGPphRK24tSooJ9euFt06Kfj 20 | WdH0lDrxz72vPhqybRnmCg+qWTATlTvOgA17s5wwIoPqMewmQYuWb9BO9cwE4msO 21 | RySclDZVw1dY+3mV+9CLfnuu32KckowU6hii/StlC+onla23JYrs54+8HiW5VWkX 22 | G1K6WrTUIBApO+m0HlRlc3QgS2V5IDx0ZXN0a2V5QGV4YW1wbGUuY29tPokBTgQT 23 | AQoAOAIbAwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgBYhBJDpQmQcB6TEZrqXFh6S 24 | 4rS7c+iFBQJfFdjFAAoJEB6S4rS7c+iFPc8IAIbJxix0puOW3QACGaWMxeBmKurI 25 | Eyi5YN7PC2rSOU6ajzAOmweDC99Le5RyjaZql+FkWHl9Bg8C8F4mZ/CbWgEpYsFU 26 | sOTBNOW/lVpRV3Y/O/0ft2kxJOJc9qikkTkVQDhvKZejEAIA4wVO2jY3ih5TsxdG 27 | 1P7bunDHb/QUAK6WyC/igmMoJaSy1utlPvJFDjlRKFntpd1KbhdxSzKHIY0wj/8k 28 | Mi3qhGRzSCSeJrqb4sw/HqnB/hmRW0/9Y/eGuiG3UmN7tkobwnEKhsaDx04o7n5v 29 | 3mUO5Tvu+VTBcKdtzjJUXmlgI47r7EssEfFOL+9SemIO2g4OlVYG+J+vyg4= 30 | =CiIN 31 | -----END PGP PRIVATE KEY BLOCK----- 32 | -------------------------------------------------------------------------------- /tutorials/cloudrun/Dockerfile-server: -------------------------------------------------------------------------------- 1 | # Builder 2 | FROM golang:1.15-alpine as builder 3 | 4 | WORKDIR /go/src/github.com/grafeas/voucher 5 | COPY . . 6 | RUN apk --no-cache add \ 7 | git \ 8 | make && \ 9 | make voucher_server 10 | 11 | # Final build 12 | FROM alpine:3.8 13 | 14 | COPY --from=builder /go/src/github.com/grafeas/voucher/build/voucher_server /usr/local/bin/voucher_server 15 | COPY --from=builder /go/src/github.com/grafeas/voucher/entrypoint.sh /usr/local/entrypoint.sh 16 | COPY --from=builder /go/src/github.com/grafeas/voucher/tutorials/cloudrun/config.toml /etc/voucher/config.toml 17 | 18 | COPY config/secrets.production.ejson /etc/voucher/secrets.production.ejson 19 | 20 | RUN apk add --no-cache \ 21 | ca-certificates && \ 22 | addgroup -S -g 10000 voucher && \ 23 | adduser -S -u 10000 -G voucher voucher 24 | 25 | USER 10000:10000 26 | 27 | ENTRYPOINT ["/usr/local/entrypoint.sh"] 28 | -------------------------------------------------------------------------------- /tutorials/cloudrun/cloudbuild-server.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | args: [ 'build', '-f', 'tutorials/cloudrun/Dockerfile-server', '-t', 'gcr.io/$PROJECT_ID/voucher-server:latest', '.' ] 4 | images: 5 | - 'gcr.io/$PROJECT_ID/voucher-server:latest' -------------------------------------------------------------------------------- /tutorials/cloudrun/config.toml.template: -------------------------------------------------------------------------------- 1 | dryrun = false 2 | scanner = "metadata" 3 | failon = "high" 4 | metadata_client = "containeranalysis" 5 | image_project = "" 6 | binauth_project = "" 7 | signer = "kms" 8 | valid_repos = [ 9 | "gcr.io/path/to/my/project", 10 | ] 11 | 12 | trusted_builder-identities = [ 13 | "email@example.com", 14 | "idcloudbuild.gserviceaccount.com" 15 | ] 16 | 17 | trusted_projects = [ 18 | "trusted-builds" 19 | ] 20 | 21 | [checks] 22 | diy = false 23 | nobody = false 24 | provenance = false 25 | snakeoil = true 26 | 27 | [server] 28 | port = 8080 29 | require_auth = false 30 | username = "username here" 31 | password = "bcrypt hash of your password" 32 | 33 | [ejson] 34 | dir = "/key" 35 | secrets = "/etc/voucher/secrets.production.ejson" 36 | 37 | [statsd] 38 | addr = "localhost:8125" 39 | sample_rate = 0.1 40 | tags = [] 41 | 42 | [repository.grafeas] 43 | org-url = "https://github.com/grafeas" 44 | 45 | [[kms_keys]] 46 | check = "snakeoil" 47 | path = "" 48 | algo = "SHA512" 49 | -------------------------------------------------------------------------------- /tutorials/cloudrun/examples/Dockerfile.bad: -------------------------------------------------------------------------------- 1 | # Debian9 image from Jun 8th, 2020 2 | FROM gcr.io/google-appengine/debian9@sha256:023748401f33e710de6297c7e7dd1617f3c3654819885c5208e9df4d0697848e 3 | 4 | # Just so the built image is always unique 5 | RUN apt-get update && apt-get -y install uuid-runtime && uuidgen > /IAMUNIQUE -------------------------------------------------------------------------------- /tutorials/cloudrun/examples/Dockerfile.good: -------------------------------------------------------------------------------- 1 | FROM alpine:3.8 2 | 3 | RUN apk add --no-cache util-linux 4 | CMD uuidgen > /IAMUNIQUE 5 | -------------------------------------------------------------------------------- /tutorials/cloudrun/examples/cloudbuild-bad.yaml: -------------------------------------------------------------------------------- 1 | # Cloudbuild pipeline for a build with an image 2 | # that passes the vuln policy 3 | steps: 4 | # Build a 'bad' image 5 | - name: gcr.io/cloud-builders/docker 6 | entrypoint: /bin/bash 7 | args: 8 | - -c 9 | - | 10 | docker build -t gcr.io/$PROJECT_ID/binauthz-test:latest -f ./Dockerfile.bad . 11 | id: build 12 | - name: gcr.io/cloud-builders/docker 13 | entrypoint: /bin/bash 14 | args: 15 | - -c 16 | - | 17 | docker push gcr.io/$PROJECT_ID/binauthz-test:latest && 18 | docker image inspect gcr.io/$PROJECT_ID/binauthz-test:latest --format '{{index .RepoDigests 0}}' > image-digest.txt && 19 | cat image-digest.txt 20 | id: push 21 | - name: gcr.io/cloud-builders/gcloud 22 | entrypoint: "bash" 23 | args: 24 | - -c 25 | - | 26 | itoken=$(curl -X POST -H "content-type: application/json" \ 27 | -H "Authorization: Bearer $(gcloud auth print-access-token)" \ 28 | -d '{"audience": "${_SERVICE_URL}"}' \ 29 | https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${_SERVICE_ACCOUNT}:generateIdToken) && \ 30 | curl -X POST \ 31 | -H "Authorization: Bearer $(echo $itoken | awk -F'\"' '{print $4}')" \ 32 | -H "Content-Type: application/json" \ 33 | -d "{\"image_url\": \"$(cat image-digest.txt)\"}" \ 34 | ${_SERVICE_URL}/all 35 | waitFor: push 36 | id: vulnsign 37 | substitutions: 38 | _SERVICE_URL: MISSING_SERVICE_URL # Use `gcloud builds submit --substitutions ` to set this value 39 | _SERVICE_ACCOUNT: MISSING_SERVICE_ACCOUNT # Use `gcloud builds submit --substitutions ` to set this value 40 | images: ['gcr.io/$PROJECT_ID/binauthz-test:latest'] -------------------------------------------------------------------------------- /tutorials/cloudrun/examples/cloudbuild-good.yaml: -------------------------------------------------------------------------------- 1 | # Cloudbuild pipeline for a build with an image 2 | # that passes the vuln policy 3 | steps: 4 | # Build a 'good' image 5 | - name: gcr.io/cloud-builders/docker 6 | entrypoint: /bin/bash 7 | args: 8 | - -c 9 | - | 10 | docker build -t gcr.io/$PROJECT_ID/binauthz-test:latest -f ./Dockerfile.good . 11 | id: build 12 | - name: gcr.io/cloud-builders/docker 13 | entrypoint: /bin/bash 14 | args: 15 | - -c 16 | - | 17 | docker push gcr.io/$PROJECT_ID/binauthz-test:latest && 18 | docker image inspect gcr.io/$PROJECT_ID/binauthz-test:latest --format '{{index .RepoDigests 0}}' > image-digest.txt && 19 | cat image-digest.txt 20 | id: push 21 | - name: gcr.io/cloud-builders/gcloud 22 | entrypoint: "bash" 23 | args: 24 | - -c 25 | - | 26 | itoken=$(curl -X POST -H "content-type: application/json" \ 27 | -H "Authorization: Bearer $(gcloud auth print-access-token)" \ 28 | -d '{"audience": "${_SERVICE_URL}"}' \ 29 | https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${_SERVICE_ACCOUNT}:generateIdToken) && \ 30 | curl -X POST \ 31 | -H "Authorization: Bearer $(echo $itoken | awk -F'\"' '{print $4}')" \ 32 | -H "Content-Type: application/json" \ 33 | -d "{\"image_url\": \"$(cat image-digest.txt)\"}" \ 34 | ${_SERVICE_URL}/all 35 | waitFor: push 36 | id: vulnsign 37 | substitutions: 38 | _SERVICE_URL: MISSING_SERVICE_URL # Use `gcloud builds submit --substitutions ` to set this value 39 | _SERVICE_ACCOUNT: MISSING_SERVICE_ACCOUNT # Use `gcloud builds submit --substitutions ` to set this value 40 | images: ['gcr.io/$PROJECT_ID/binauthz-test:latest'] -------------------------------------------------------------------------------- /v2/attestation.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "github.com/grafeas/voucher/v2/signer" 5 | ) 6 | 7 | // Attestation is a structure that contains the Attestation data that we want 8 | // to create an MetadataItem from. 9 | type Attestation struct { 10 | CheckName string 11 | Body string 12 | } 13 | 14 | // NewAttestation creates a new Attestation for the check with the passed name, 15 | // with the payload as the body. The payload will then be signed by the key associated 16 | // with the check (referenced by the checkName). 17 | func NewAttestation(checkName string, payload string) Attestation { 18 | return Attestation{ 19 | CheckName: checkName, 20 | Body: payload, 21 | } 22 | } 23 | 24 | // SignedAttestation is a structure that contains the Attestation data as well 25 | // as the signature and signing key ID. 26 | type SignedAttestation struct { 27 | Attestation 28 | Signature string 29 | KeyID string 30 | } 31 | 32 | // SignAttestation takes a keyring and attestation and signs the body of the 33 | // payload with it, updating the Attestation's Signature field. 34 | func SignAttestation(s signer.AttestationSigner, attestation Attestation) (SignedAttestation, error) { 35 | signature, keyID, err := s.Sign(attestation.CheckName, attestation.Body) 36 | if nil != err { 37 | return SignedAttestation{}, err 38 | } 39 | 40 | return SignedAttestation{ 41 | Attestation: attestation, 42 | Signature: signature, 43 | KeyID: keyID, 44 | }, nil 45 | } 46 | 47 | // SignedAttestationToResult returns a CheckResults from the SignedAttestation 48 | // passed to it. Check names is set as appropriate. 49 | func SignedAttestationToResult(attestation SignedAttestation) CheckResult { 50 | return CheckResult{ 51 | Name: attestation.CheckName, 52 | Success: true, 53 | Attested: true, 54 | Details: attestation, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /v2/attestation/payload.go: -------------------------------------------------------------------------------- 1 | package attestation 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/docker/distribution/reference" 7 | "github.com/opencontainers/go-digest" 8 | ) 9 | 10 | const payloadType = "Google cloud binauthz container signature" 11 | 12 | // PayloadIdentity represents the identity block in an Payload message. 13 | type PayloadIdentity struct { 14 | DockerReference string `json:"docker-reference"` 15 | } 16 | 17 | // PayloadImage represents the image block in an Payload message. 18 | type PayloadImage struct { 19 | DockerManifestDigest digest.Digest `json:"docker-manifest-digest"` 20 | } 21 | 22 | // PayloadCritical represents the critical block in the Payload message. 23 | type PayloadCritical struct { 24 | Identity PayloadIdentity `json:"identity"` 25 | Image PayloadImage `json:"image"` 26 | Type string `json:"type"` 27 | } 28 | 29 | // Payload represents an Payload message. 30 | type Payload struct { 31 | Critical PayloadCritical `json:"critical"` 32 | } 33 | 34 | // ToString returns the payload as a JSON encoded string, or returns an error. 35 | func (p Payload) ToString() (string, error) { 36 | b, err := json.Marshal(p) 37 | if err != nil { 38 | return "", err 39 | } 40 | return string(b), nil 41 | } 42 | 43 | // NewPayload creates a new Binauth specific payload for the image at 44 | // the passed URL. 45 | func NewPayload(reference reference.Canonical) Payload { 46 | payload := Payload{ 47 | Critical: PayloadCritical{ 48 | Identity: PayloadIdentity{ 49 | DockerReference: reference.Name(), 50 | }, 51 | Image: PayloadImage{ 52 | DockerManifestDigest: reference.Digest(), 53 | }, 54 | Type: payloadType, 55 | }, 56 | } 57 | 58 | return payload 59 | } 60 | -------------------------------------------------------------------------------- /v2/attestation/payload_test.go: -------------------------------------------------------------------------------- 1 | package attestation 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/docker/distribution/reference" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | const testPayloadURL = "gcr.io/test/image/we/are/testing@sha256:8c733d1c02c464f484bccd5fda4bc560040b80e105e431f17e1b1c3fce9b7d27" 12 | 13 | const testPayloadOutput = `{ 14 | "critical": { 15 | "identity": { 16 | "docker-reference": "gcr.io/test/image/we/are/testing" 17 | }, 18 | "image": { 19 | "docker-manifest-digest": "sha256:8c733d1c02c464f484bccd5fda4bc560040b80e105e431f17e1b1c3fce9b7d27" 20 | }, 21 | "type": "Google cloud binauthz container signature" 22 | } 23 | }` 24 | 25 | func TestNewPayload(t *testing.T) { 26 | assert := assert.New(t) 27 | 28 | rawRef, err := reference.Parse(testPayloadURL) 29 | assert.Nil(err) 30 | 31 | canonicalRef, isCanonical := rawRef.(reference.Canonical) 32 | assert.True(isCanonical) 33 | 34 | payload := NewPayload(canonicalRef) 35 | 36 | b, err := json.MarshalIndent(&payload, "", " ") 37 | assert.Nil(err) 38 | 39 | assert.Equal(testPayloadOutput, string(b)) 40 | } 41 | -------------------------------------------------------------------------------- /v2/auth.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/docker/distribution/reference" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | // ErrNoAuth should be returned when something that depends on an Auth does not 13 | // have one. 14 | var ErrNoAuth = errors.New("no configured Auth") 15 | 16 | // Auth is an interface that wraps an to an OAuth2 system, to simplify the path 17 | // from having an image reference to getting access to the data that makes up 18 | // that image from the registry it lives in. 19 | type Auth interface { 20 | GetTokenSource(context.Context, reference.Named) (oauth2.TokenSource, error) 21 | ToClient(ctx context.Context, image reference.Named) (*http.Client, error) 22 | IsForDomain(url reference.Named) bool 23 | } 24 | 25 | // AuthToClient takes a struct implementing Auth and returns a new http.Client 26 | // with the authentication details setup by Auth.GetTokenSource. 27 | // 28 | // DEPRECATED: This function has been superceded by Auth.ToClient. This function 29 | // now calls that method directly. 30 | func AuthToClient(ctx context.Context, auth Auth, image reference.Named) (*http.Client, error) { 31 | return auth.ToClient(ctx, image) 32 | } 33 | -------------------------------------------------------------------------------- /v2/auth/connections.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "net/http" 7 | "time" 8 | 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | // DefaultTransport is a custom implementation of the DefaultTransport. 13 | // It limits the IdleConnTimeout to 10 seconds instead of 90 seconds. 14 | // 15 | // from "net/http/transport.go" 16 | var DefaultTransport http.RoundTripper = &http.Transport{ 17 | Proxy: http.ProxyFromEnvironment, 18 | DialContext: (&net.Dialer{ 19 | Timeout: 30 * time.Second, 20 | KeepAlive: 30 * time.Second, 21 | DualStack: true, 22 | }).DialContext, 23 | MaxIdleConns: 100, 24 | IdleConnTimeout: 10 * time.Second, 25 | TLSHandshakeTimeout: 10 * time.Second, 26 | ExpectContinueTimeout: 1 * time.Second, 27 | } 28 | 29 | // ErrCannotUpdateIdleConnTimeout is an error returned when the Transport 30 | // is unable to be updated. 31 | var ErrCannotUpdateIdleConnTimeout = errors.New("cannot update transport") 32 | 33 | // UpdateIdleConnectionsTimeout limits the default timeout for idle connections 34 | // to 10 seconds. If the OAuth2 transport is nil, redefine it with our own 35 | // DefaultTransport. This should limit the number of idle connections left 36 | // open after the initial requests are made, which in turn should reduce the 37 | // chances of the number of available connections being depleted. 38 | func UpdateIdleConnectionsTimeout(client *http.Client) error { 39 | httpTransport, ok := client.Transport.(*http.Transport) 40 | if ok { 41 | httpTransport.IdleConnTimeout = 10 * time.Second 42 | return nil 43 | } 44 | 45 | oauth2Transport, ok := client.Transport.(*oauth2.Transport) 46 | if ok && oauth2Transport.Base == nil { 47 | oauth2Transport.Base = DefaultTransport 48 | 49 | return nil 50 | } 51 | 52 | return ErrCannotUpdateIdleConnTimeout 53 | } 54 | -------------------------------------------------------------------------------- /v2/auth/error.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/docker/distribution/reference" 7 | ) 8 | 9 | // Error is the error returned when authenticating while pulling manifests connecting to protected systems 10 | type Error struct { 11 | Reason string 12 | ImageName reference.Named 13 | } 14 | 15 | // Error returns a string for logging purposes 16 | func (e *Error) Error() string { 17 | return fmt.Sprintf("auth failed: %s for %s", e.Reason, e.ImageName) 18 | } 19 | 20 | // NewAuthError returns an AuthError struct with the reason for erroring, as well as the Name of the image reference 21 | func NewAuthError(reason string, imageName reference.Named) error { 22 | return &Error{reason, imageName} 23 | } 24 | -------------------------------------------------------------------------------- /v2/auth/google/auth.go: -------------------------------------------------------------------------------- 1 | package google 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/docker/distribution/reference" 10 | "golang.org/x/oauth2" 11 | "golang.org/x/oauth2/google" 12 | 13 | voucher "github.com/grafeas/voucher/v2" 14 | "github.com/grafeas/voucher/v2/auth" 15 | ) 16 | 17 | const gcrScope = "https://www.googleapis.com/auth/cloud-platform" 18 | 19 | // GoogleAuth wraps the Google OAuth2 code. 20 | type gAuth struct { 21 | } 22 | 23 | // GetTokenSource gets the default oauth2.TokenSource for connecting to Google's, 24 | // OAuth2 protected systems, based on the runtime environment, or returns error 25 | // if there's an issue getting the token source. 26 | func (a *gAuth) GetTokenSource(ctx context.Context, ref reference.Named) (oauth2.TokenSource, error) { 27 | source, err := google.DefaultTokenSource(ctx, gcrScope) 28 | if nil != err { 29 | err = fmt.Errorf("failed to get Google Auth token source: %s", err) 30 | } 31 | 32 | return source, err 33 | } 34 | 35 | // ToClient returns a new http.Client with the authentication details setup by 36 | // Auth.GetTokenSource. 37 | func (a *gAuth) ToClient(ctx context.Context, image reference.Named) (*http.Client, error) { 38 | if !a.IsForDomain(image) { 39 | return nil, auth.NewAuthError("does not match domain", image) 40 | } 41 | 42 | tokenSource, err := a.GetTokenSource(ctx, image) 43 | if nil != err { 44 | return nil, err 45 | } 46 | 47 | client := oauth2.NewClient(ctx, tokenSource) 48 | err = auth.UpdateIdleConnectionsTimeout(client) 49 | 50 | return client, err 51 | } 52 | 53 | // IsForDomain validates the domain part of the Named image reference 54 | func (a *gAuth) IsForDomain(image reference.Named) bool { 55 | domain := reference.Domain(image) 56 | if domain == "gcr.io" { 57 | // Google Container Registry 58 | return true 59 | } 60 | 61 | // Google Artifact Registry 62 | return strings.HasSuffix(domain, ".pkg.dev") 63 | } 64 | 65 | // NewAuth returns a new voucher.Auth to access Google specific resources. 66 | func NewAuth() voucher.Auth { 67 | return new(gAuth) 68 | } 69 | -------------------------------------------------------------------------------- /v2/authorizedcheck.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // AuthorizedCheck represents a Voucher check that needs to be authorized. 4 | // For example, a check that needs to connect to the registry will 5 | // need to implement AuthorizedCheck. 6 | type AuthorizedCheck interface { 7 | Check 8 | SetAuth(Auth) 9 | } 10 | -------------------------------------------------------------------------------- /v2/check.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // ErrNoCheck is an error that is returned when a requested check hasn't 9 | // been registered. 10 | var ErrNoCheck = errors.New("requested check doesn't exist") 11 | 12 | // Check represents a Voucher test. 13 | type Check interface { 14 | Check(context.Context, ImageData) (bool, error) 15 | } 16 | -------------------------------------------------------------------------------- /v2/checks/diy/check.go: -------------------------------------------------------------------------------- 1 | package diy 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | voucher "github.com/grafeas/voucher/v2" 9 | "github.com/grafeas/voucher/v2/docker" 10 | ) 11 | 12 | // ErrNotFromRepo is returned when an image does not match one of the valid 13 | // repo paths. 14 | var ErrNotFromRepo = errors.New("image is not from a valid repo") 15 | 16 | // check is a check that verifies if the passed image was built 17 | // by us. 18 | type check struct { 19 | auth voucher.Auth 20 | validRepos []string 21 | } 22 | 23 | // SetValidRepos sets the repos that images must be in to get signed by the 24 | // DIY check. 25 | func (d *check) SetValidRepos(repos []string) { 26 | d.validRepos = repos 27 | } 28 | 29 | // SetAuth sets the authentication system that this check will use 30 | // for its run. 31 | func (d *check) SetAuth(auth voucher.Auth) { 32 | d.auth = auth 33 | } 34 | 35 | // isFromValidrepo returns true if the passed image is from a valid repo. 36 | func (d *check) isFromValidRepo(i voucher.ImageData) bool { 37 | for _, repo := range d.validRepos { 38 | if strings.HasPrefix(i.Name(), repo) { 39 | return true 40 | } 41 | } 42 | return false 43 | } 44 | 45 | // check checks if an image was built by a trusted source 46 | func (d *check) Check(ctx context.Context, i voucher.ImageData) (bool, error) { 47 | if !d.isFromValidRepo(i) { 48 | return false, ErrNotFromRepo 49 | } 50 | 51 | if nil == d.auth { 52 | return false, voucher.ErrNoAuth 53 | } 54 | 55 | client, err := d.auth.ToClient(ctx, i) 56 | if nil != err { 57 | return false, err 58 | } 59 | 60 | _, err = docker.RequestImageConfig(client, i) 61 | if nil != err { 62 | return false, err 63 | } 64 | 65 | return true, nil 66 | } 67 | 68 | func init() { 69 | voucher.RegisterCheckFactory("diy", func() voucher.Check { 70 | return new(check) 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /v2/checks/diy/check_test.go: -------------------------------------------------------------------------------- 1 | package diy 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | voucher "github.com/grafeas/voucher/v2" 11 | vtesting "github.com/grafeas/voucher/v2/testing" 12 | ) 13 | 14 | func TestDIYCheck(t *testing.T) { 15 | server := vtesting.NewTestDockerServer(t) 16 | 17 | i := vtesting.NewTestReference(t) 18 | 19 | diyCheck := new(check) 20 | diyCheck.SetAuth(vtesting.NewAuth(server)) 21 | diyCheck.SetValidRepos([]string{ 22 | i.Name(), 23 | }) 24 | 25 | pass, err := diyCheck.Check(context.Background(), i) 26 | 27 | assert.NoErrorf(t, err, "check failed with error: %s", err) 28 | assert.True(t, pass, "check failed when it should have passed") 29 | } 30 | 31 | func TestDIYCheckWithInvalidRepo(t *testing.T) { 32 | i := vtesting.NewTestReference(t) 33 | 34 | diyCheck := new(check) 35 | 36 | // run check without setting up valid repos. 37 | pass, err := diyCheck.Check(context.Background(), i) 38 | 39 | assert.Equal(t, err, ErrNotFromRepo, "check should have failed due to image not being from a valid repo, but didn't") 40 | assert.False(t, pass, "check passed when it should have failed due to image being from an invalid repo") 41 | } 42 | 43 | func TestDIYCheckWithNoAuth(t *testing.T) { 44 | i := vtesting.NewTestReference(t) 45 | 46 | diyCheck := new(check) 47 | diyCheck.SetValidRepos([]string{ 48 | i.Name(), 49 | }) 50 | 51 | // run check without setting up Auth. 52 | pass, err := diyCheck.Check(context.Background(), i) 53 | 54 | assert.Equal(t, err, voucher.ErrNoAuth, "check should have failed due to lack of Auth, but didn't") 55 | assert.False(t, pass, "check passed when it should have failed due to no Auth") 56 | } 57 | 58 | func TestFailingDIYCheck(t *testing.T) { 59 | server := vtesting.NewTestDockerServer(t) 60 | 61 | auth := vtesting.NewAuth(server) 62 | 63 | i := vtesting.NewBadTestReference(t) 64 | 65 | diyCheck := new(check) 66 | diyCheck.SetAuth(auth) 67 | diyCheck.SetValidRepos([]string{ 68 | i.Name(), 69 | }) 70 | 71 | pass, err := diyCheck.Check(context.Background(), i) 72 | 73 | require.Error(t, err, "check should have failed with error, but didn't") 74 | assert.Containsf(t, err.Error(), "image doesn't exist", "check error format is incorrect, should be \"image doesn't exist\": \"%s\"", err) 75 | assert.False(t, pass, "check passed when it should have failed") 76 | } 77 | -------------------------------------------------------------------------------- /v2/checks/nobody/check.go: -------------------------------------------------------------------------------- 1 | package nobody 2 | 3 | import ( 4 | "context" 5 | 6 | voucher "github.com/grafeas/voucher/v2" 7 | "github.com/grafeas/voucher/v2/docker" 8 | ) 9 | 10 | // check is for verifying that the passed image does not run as 11 | // root or user 0. 12 | type check struct { 13 | auth voucher.Auth 14 | } 15 | 16 | // SetAuth sets the authentication system that this check will use 17 | // for its run. 18 | func (n *check) SetAuth(auth voucher.Auth) { 19 | n.auth = auth 20 | } 21 | 22 | // Check verifies if the image runs as root and returns a boolean (true if 23 | // the user is not root, false otherwise) and an error as response. 24 | func (n *check) Check(ctx context.Context, i voucher.ImageData) (bool, error) { 25 | if nil == n.auth { 26 | return false, voucher.ErrNoAuth 27 | } 28 | 29 | client, err := n.auth.ToClient(ctx, i) 30 | if nil != err { 31 | return false, err 32 | } 33 | 34 | imageConfig, err := docker.RequestImageConfig(client, i) 35 | 36 | if nil != err { 37 | return false, err 38 | } 39 | 40 | return !imageConfig.RunsAsRoot(), nil 41 | } 42 | 43 | func init() { 44 | voucher.RegisterCheckFactory("nobody", func() voucher.Check { 45 | return new(check) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /v2/checks/org/check.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | "github.com/grafeas/voucher/v2/repository" 9 | ) 10 | 11 | // ErrNoBuildData is an error returned if we can't pull any BuildData from 12 | // Grafeas for an image. 13 | var ErrNoBuildData = errors.New("no build metadata associated with this image") 14 | 15 | // ErrNoRepositoryClient is an error returned if we can't connect to the source code repository for an image. 16 | var ErrNoRepositoryClient = errors.New("no repository client configured for check") 17 | 18 | // check holds the required data for the check 19 | type check struct { 20 | metadataClient voucher.MetadataClient 21 | repositoryClient repository.Client 22 | org repository.Organization 23 | } 24 | 25 | // SetMetadataClient sets the MetadataClient for this Check. 26 | func (o *check) SetMetadataClient(metadataClient voucher.MetadataClient) { 27 | o.metadataClient = metadataClient 28 | } 29 | 30 | // SetRepositoryClient sets the repository client for this Check. 31 | func (o *check) SetRepositoryClient(repositoryClient repository.Client) { 32 | o.repositoryClient = repositoryClient 33 | } 34 | 35 | // Check runs the org check 36 | func (o *check) Check(ctx context.Context, i voucher.ImageData) (bool, error) { 37 | buildDetail, err := o.metadataClient.GetBuildDetail(ctx, i) 38 | if err != nil { 39 | if voucher.IsNoMetadataError(err) { 40 | return false, ErrNoBuildData 41 | } 42 | return false, err 43 | } 44 | 45 | if o.repositoryClient == nil { 46 | return false, ErrNoRepositoryClient 47 | } 48 | 49 | org, err := o.repositoryClient.GetOrganization(ctx, buildDetail) 50 | if err != nil { 51 | return false, err 52 | } 53 | if org.Name != o.org.Name { 54 | return false, nil 55 | } 56 | 57 | return true, nil 58 | } 59 | 60 | func NewOrganizationCheckFactory(organization repository.Organization) voucher.CheckFactory { 61 | // Return a voucher.CheckFactory that always creates the desired OrganizationCheck 62 | return func() voucher.Check { 63 | return &check{ 64 | org: organization, 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /v2/checks/org/check_test.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | "github.com/stretchr/testify/require" 10 | 11 | voucher "github.com/grafeas/voucher/v2" 12 | "github.com/grafeas/voucher/v2/repository" 13 | r "github.com/grafeas/voucher/v2/repository" 14 | ) 15 | 16 | func TestOrgCheck(t *testing.T) { 17 | c := context.Background() 18 | 19 | i, err := voucher.NewImageData("gcr.io/voucher-test-project/apps/staging/voucher-internal@sha256:73d506a23331fce5cb6f49bfb4c27450d2ef4878efce89f03a46b27372a88430") 20 | require.NoErrorf(t, err, "failed to get ImageData: %s", err) 21 | details := r.BuildDetail{RepositoryURL: "https://github.com/Shopify/app", Commit: "efgh6543"} 22 | organization := r.Organization{Name: "Shopify", VCS: "github.com"} 23 | 24 | repoClient := new(r.MockClient) 25 | repoClient.On("GetOrganization", mock.Anything, details).Return(organization, nil) 26 | 27 | metadataClient := new(voucher.MockMetadataClient) 28 | metadataClient.On("GetBuildDetail", mock.Anything, i).Return(details, nil) 29 | 30 | orgCheck := new(check) 31 | orgCheck.org = organization 32 | orgCheck.SetRepositoryClient(repoClient) 33 | orgCheck.SetMetadataClient(metadataClient) 34 | 35 | status, err := orgCheck.Check(c, i) 36 | 37 | assert.NoErrorf(t, err, "check failed with error: %s", err) 38 | assert.True(t, status, "check failed when it should have passed") 39 | } 40 | 41 | func TestOrgCheckWithInvalidRepo(t *testing.T) { 42 | c := context.Background() 43 | 44 | i, err := voucher.NewImageData("gcr.io/voucher-test-project/apps/staging/voucher-internal@sha256:73d506a23331fce5cb6f49bfb4c27450d2ef4878efce89f03a46b27372a88430") 45 | require.NoErrorf(t, err, "failed to get ImageData: %s", err) 46 | details := r.BuildDetail{RepositoryURL: "git@github.com/TestOrg/TestRepo.git", Commit: "cdef0987"} 47 | organization := r.Organization{Name: "Shopify", VCS: "github.com"} 48 | 49 | repoClient := new(r.MockClient) 50 | repoClient.On("GetOrganization", mock.Anything, details).Return(repository.Organization{}, nil) 51 | 52 | metadataClient := new(voucher.MockMetadataClient) 53 | metadataClient.On("GetBuildDetail", mock.Anything, i).Return(details, nil) 54 | 55 | orgCheck := new(check) 56 | orgCheck.org = organization 57 | orgCheck.SetRepositoryClient(repoClient) 58 | orgCheck.SetMetadataClient(metadataClient) 59 | 60 | status, err := orgCheck.Check(c, i) 61 | 62 | assert.NoErrorf(t, err, "check failed with error: %s", err) 63 | assert.False(t, status, "check passed when it should have failed") 64 | } 65 | -------------------------------------------------------------------------------- /v2/checks/provenance/check_test.go: -------------------------------------------------------------------------------- 1 | package provenance 2 | 3 | import ( 4 | "testing" 5 | 6 | voucher "github.com/grafeas/voucher/v2" 7 | "github.com/grafeas/voucher/v2/repository" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | var ( 13 | builderIdentityTestData = "trusted-person@email.com" 14 | imageSHA256TestData = "sha256:1234c923e00e0fd2ba78041bfb64a105e1ecb7678916d1f7776311e45bf57890" 15 | imageURLTestData = "gcr.io/" + projectTestData + "/name@" + imageSHA256TestData 16 | projectTestData = "test" 17 | ) 18 | 19 | var buildDetailsTestData = repository.BuildDetail{ 20 | ProjectID: projectTestData, 21 | BuildCreator: builderIdentityTestData, 22 | Artifacts: []repository.BuildArtifact{ 23 | { 24 | ID: imageURLTestData, 25 | Checksum: imageSHA256TestData, 26 | }, 27 | }, 28 | } 29 | 30 | func TestArtifactIsImage(t *testing.T) { 31 | imageDataTestData, err := voucher.NewImageData(imageURLTestData) 32 | require.NoError(t, err) 33 | 34 | assert := assert.New(t) 35 | result := validateArtifacts(imageDataTestData, buildDetailsTestData) 36 | assert.True(result) 37 | } 38 | -------------------------------------------------------------------------------- /v2/checks/snakeoil/check.go: -------------------------------------------------------------------------------- 1 | package snakeoil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | // ErrNoScanner is the error thrown when there is no SnakeoilScanner set for 11 | // the Snakeoil Check. 12 | var ErrNoScanner = errors.New("no scanner configured for snakeoil") 13 | 14 | // check verifies if there are any known vulnerabilities for the 15 | // passed image. 16 | type check struct { 17 | scanner voucher.VulnerabilityScanner 18 | } 19 | 20 | // SetScanner sets the scanner that Snakeoil should use. 21 | func (s *check) SetScanner(newScanner voucher.VulnerabilityScanner) { 22 | s.scanner = newScanner 23 | } 24 | 25 | // Check verifies if the image has known vulnerabilities 26 | func (s *check) Check(ctx context.Context, i voucher.ImageData) (bool, error) { 27 | if nil == s.scanner { 28 | return false, ErrNoScanner 29 | } 30 | 31 | vulns, err := s.scanner.Scan(ctx, i) 32 | if nil != err { 33 | return false, err 34 | } 35 | 36 | if 0 != len(vulns) { 37 | return false, voucher.NewVulnerabilityError(vulns) 38 | } 39 | 40 | return true, nil 41 | } 42 | 43 | func init() { 44 | voucher.RegisterCheckFactory("snakeoil", func() voucher.Check { 45 | return new(check) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /v2/client/check.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "path" 6 | 7 | "github.com/docker/distribution/reference" 8 | voucher "github.com/grafeas/voucher/v2" 9 | ) 10 | 11 | // Check executes a request to a Voucher server, to the appropriate check URI, and 12 | // with the passed reference.Canonical. Returns a voucher.Response and an error. 13 | func (c *Client) Check(ctx context.Context, check string, image reference.Canonical) (voucher.Response, error) { 14 | url := c.toVoucherCheckURL(check) 15 | resp, err := c.doVoucherRequest(ctx, url, image) 16 | if err != nil { 17 | return voucher.Response{}, err 18 | } 19 | return *resp, nil 20 | } 21 | 22 | func (c *Client) toVoucherCheckURL(checkname string) string { 23 | newVoucherURL := c.CopyURL() 24 | newVoucherURL.Path = path.Join(newVoucherURL.Path, checkname) 25 | return newVoucherURL.String() 26 | } 27 | -------------------------------------------------------------------------------- /v2/client/verify.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "path" 6 | 7 | "github.com/docker/distribution/reference" 8 | 9 | voucher "github.com/grafeas/voucher/v2" 10 | ) 11 | 12 | func (c *Client) Verify(ctx context.Context, check string, image reference.Canonical) (voucher.Response, error) { 13 | url := c.toVoucherVerifyURL(check) 14 | resp, err := c.doVoucherRequest(ctx, url, image) 15 | if err != nil { 16 | return voucher.Response{}, err 17 | } 18 | return *resp, nil 19 | } 20 | 21 | func (c *Client) toVoucherVerifyURL(checkname string) string { 22 | newVoucherURL := c.CopyURL() 23 | newVoucherURL.Path = path.Join(newVoucherURL.Path, checkname, "verify") 24 | return newVoucherURL.String() 25 | } 26 | -------------------------------------------------------------------------------- /v2/cmd/config/auth.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | voucher "github.com/grafeas/voucher/v2" 5 | "github.com/grafeas/voucher/v2/auth/google" 6 | ) 7 | 8 | func newAuth() voucher.Auth { 9 | return google.NewAuth() 10 | } 11 | -------------------------------------------------------------------------------- /v2/cmd/config/cloudrun.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func IsCloudRun() bool { 8 | return os.Getenv("IS_CLOUDRUN") == "true" 9 | } 10 | -------------------------------------------------------------------------------- /v2/cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | // FileName is the filename for the voucher configuration 9 | var FileName string 10 | 11 | // InitConfig searches for and loads configuration file for voucher 12 | func InitConfig() { 13 | log.Println("initconfig") 14 | 15 | if FileName != "" { 16 | viper.SetConfigFile(FileName) 17 | } else { 18 | viper.SetConfigName("config") // name of config file (without extension) 19 | viper.AddConfigPath("/etc/voucher/") // path to look for the config file in 20 | viper.AddConfigPath("$HOME/.voucher") 21 | viper.AddConfigPath("./config") 22 | viper.AddConfigPath(".") // optionally look for config in the working directory 23 | } 24 | err := viper.ReadInConfig() 25 | if err != nil { 26 | log.Fatalf("config file: %s \n", err) 27 | } 28 | viper.AutomaticEnv() 29 | } 30 | -------------------------------------------------------------------------------- /v2/cmd/config/get_orgs_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/viper" 6 | 7 | "github.com/grafeas/voucher/v2/repository" 8 | ) 9 | 10 | func GetOrganizationsFromConfig() map[string]repository.Organization { 11 | orgs := make(map[string]repository.Organization) 12 | repositories := viper.GetStringMap("repository") 13 | if nil == repositories { 14 | repositories = map[string]interface{}{} 15 | } 16 | for alias, val := range repositories { 17 | if m, ok := val.(map[string]interface{}); ok { 18 | url := m["org-url"].(string) 19 | org := *repository.NewOrganization(alias, url) 20 | orgs[org.Alias] = *repository.NewOrganization(alias, url) 21 | } 22 | } 23 | if len(orgs) == 0 { 24 | log.Warning("no repositories found") 25 | } 26 | return orgs 27 | } 28 | -------------------------------------------------------------------------------- /v2/cmd/config/get_orgs_config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafeas/voucher/v2/repository" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetOrganizationsFromConfig(t *testing.T) { 11 | FileName = "../../../testdata/config.toml" 12 | InitConfig() 13 | 14 | expected := map[string]repository.Organization{ 15 | "shopify": { 16 | Alias: "shopify", 17 | Name: "Shopify", 18 | VCS: "github.com", 19 | }, 20 | } 21 | got := GetOrganizationsFromConfig() 22 | 23 | assert.Equal(t, expected, got) 24 | } 25 | -------------------------------------------------------------------------------- /v2/cmd/config/get_required_checks.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | ) 6 | 7 | func GetRequiredChecksFromConfig() map[string][]string { 8 | requiredChecks := make(map[string][]string) 9 | 10 | requiredChecks["all"] = toStringSlice(viper.GetStringMap("checks")) 11 | 12 | requirements := viper.GetStringMap("required") 13 | if nil == requirements { 14 | requirements = map[string]interface{}{} 15 | } 16 | for env, val := range requirements { 17 | if m, ok := val.(map[string]interface{}); ok { 18 | requiredChecks[env] = toStringSlice(m) 19 | } 20 | } 21 | return requiredChecks 22 | } 23 | 24 | // toStringSlice takes a map[string]interface{} and converts it to a 25 | // slice of strings using the keys (dropping any values that do not cast to 26 | // booleans cleanly, or have the value of false). 27 | func toStringSlice(in map[string]interface{}) []string { 28 | out := make([]string, 0, len(in)) 29 | for key, rawValue := range in { 30 | if value, ok := rawValue.(bool); ok { 31 | if value { 32 | out = append(out, key) 33 | } 34 | } 35 | } 36 | return out 37 | } 38 | -------------------------------------------------------------------------------- /v2/cmd/config/get_required_checks_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var testExpectedSlice = []string{ 11 | "a", 12 | "f", 13 | } 14 | 15 | var testGoodMap = map[string]interface{}{ 16 | "a": true, 17 | "b": false, 18 | "c": false, 19 | "e": 55, 20 | "f": true, 21 | } 22 | 23 | func TestGetRequiredChecksFromConfig(t *testing.T) { 24 | FileName = "../../../testdata/config.toml" 25 | InitConfig() 26 | 27 | expected := map[string][]string{ 28 | "all": { 29 | "diy", 30 | "nobody", 31 | "provenance", 32 | "snakeoil", 33 | }, 34 | "env1": { 35 | "diy", 36 | }, 37 | "env2": { 38 | "diy", 39 | "nobody", 40 | }, 41 | } 42 | got := GetRequiredChecksFromConfig() 43 | for groupName, expectedChecks := range expected { 44 | t.Logf("%s: %s", groupName, strings.Join(expectedChecks, ", ")) 45 | } 46 | for groupName, expectedChecks := range got { 47 | t.Logf("%s: %s", groupName, strings.Join(expectedChecks, ", ")) 48 | } 49 | 50 | assert.Equal(t, len(expected), len(got)) 51 | for groupName, expectedChecks := range expected { 52 | gotChecks, ok := got[groupName] 53 | assert.True(t, ok) 54 | assert.ElementsMatch(t, expectedChecks, gotChecks) 55 | } 56 | } 57 | 58 | func TestToStringSlice(t *testing.T) { 59 | convert := toStringSlice(testGoodMap) 60 | assert.ElementsMatch(t, testExpectedSlice, convert) 61 | } 62 | -------------------------------------------------------------------------------- /v2/cmd/config/kms.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/grafeas/voucher/v2/signer/kms" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | func getKMSKeyRing() (*kms.Signer, error) { 10 | rows, ok := viper.Get("kms_keys").([]interface{}) 11 | if !ok { 12 | log.Warning("KMS keys not configured") 13 | return nil, nil 14 | } 15 | 16 | keys := make(map[string]kms.Key) 17 | for _, row := range rows { 18 | if m, ok := row.(map[string]interface{}); ok { 19 | check := m["check"].(string) 20 | path := m["path"].(string) 21 | algo := m["algo"].(string) 22 | keys[check] = kms.Key{Path: path, Algo: algo} 23 | } else { 24 | continue 25 | } 26 | } 27 | 28 | return kms.NewSigner(keys) 29 | } 30 | -------------------------------------------------------------------------------- /v2/cmd/config/metrics_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/grafeas/voucher/v2/cmd/config" 8 | "github.com/grafeas/voucher/v2/metrics" 9 | "github.com/spf13/viper" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestMetricsClient(t *testing.T) { 15 | cases := map[string]struct { 16 | config string 17 | expectedType metrics.Client 18 | }{ 19 | "disabled": { 20 | expectedType: &metrics.NoopClient{}, 21 | }, 22 | "statsd from [statsd]": { 23 | config: ` 24 | [statsd] 25 | backend = "statsd" 26 | addr = "localhost:8125" 27 | `, 28 | expectedType: &metrics.StatsdClient{}, 29 | }, 30 | "statsd from [metrics]": { 31 | config: ` 32 | [metrics] 33 | backend = "statsd" 34 | [statsd] 35 | addr = "localhost:8125" 36 | `, 37 | expectedType: &metrics.StatsdClient{}, 38 | }, 39 | "datadog from [statsd]": { 40 | config: ` 41 | [statsd] 42 | backend = "datadog" 43 | `, 44 | expectedType: &metrics.DatadogClient{}, 45 | }, 46 | "datadog from [metrics]": { 47 | config: ` 48 | [metrics] 49 | backend = "datadog" 50 | `, 51 | expectedType: &metrics.DatadogClient{}, 52 | }, 53 | "otel from [metrics]": { 54 | config: ` 55 | [metrics] 56 | backend = "opentelemetry" 57 | [opentelemetry] 58 | addr = "http://localhost:4317" 59 | `, 60 | expectedType: &metrics.OpenTelemetryClient{}, 61 | }, 62 | } 63 | 64 | for label, tc := range cases { 65 | t.Run(label, func(t *testing.T) { 66 | viper.Reset() 67 | viper.SetConfigType("toml") 68 | err := viper.ReadConfig(strings.NewReader(tc.config)) 69 | require.NoError(t, err) 70 | 71 | client, err := config.MetricsClient(&config.Secrets{ 72 | Datadog: config.DatadogSecrets{ 73 | APIKey: "api-key", 74 | AppKey: "app-key", 75 | }, 76 | }) 77 | require.NoError(t, err) 78 | assert.IsType(t, tc.expectedType, client) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /v2/cmd/config/register.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | voucher "github.com/grafeas/voucher/v2" 7 | "github.com/grafeas/voucher/v2/checks/org" 8 | ) 9 | 10 | func RegisterDynamicChecks() { 11 | orgs := GetOrganizationsFromConfig() 12 | for alias, organization := range orgs { 13 | orgCheck := org.NewOrganizationCheckFactory(organization) 14 | voucher.RegisterCheckFactory("is_"+strings.ToLower(alias), orgCheck) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /v2/cmd/config/repoclient.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/grafeas/voucher/v2/repository" 8 | "github.com/grafeas/voucher/v2/repository/github" 9 | ) 10 | 11 | // NewRepositoryClient creates a new repository.Client for the given repository URL. The URL may be in any known 12 | // format including, but not limited to, urls starting with 'http://', 'https://', 'git@', etc. 13 | func NewRepositoryClient(ctx context.Context, keyring repository.KeyRing, repoURL string) (repository.Client, error) { 14 | org := repository.NewOrganization("", repoURL) 15 | if nil == org { 16 | return nil, fmt.Errorf("error parsing url %s", repoURL) 17 | } 18 | 19 | token, err := getTokenForOrg(keyring, *org) 20 | if nil != err { 21 | return nil, err 22 | } 23 | 24 | switch org.VCS { 25 | case "github.com": 26 | return github.NewClient(context.Background(), token) 27 | } 28 | 29 | return nil, fmt.Errorf("unknown repository %s", repoURL) 30 | } 31 | 32 | func getTokenForOrg(keyring repository.KeyRing, org repository.Organization) (*repository.Auth, error) { 33 | orgs := GetOrganizationsFromConfig() 34 | if alias, ok := getOrgAlias(orgs, org); ok { 35 | token := keyring[alias] 36 | return &token, nil 37 | } 38 | 39 | return nil, fmt.Errorf("failed to get token for %s", org.Alias) 40 | } 41 | 42 | func getOrgAlias(orgs map[string]repository.Organization, repoOrg repository.Organization) (matchingKey string, foundMatch bool) { 43 | var matchLength int 44 | var longestMatch string 45 | 46 | for alias, org := range orgs { 47 | if !isMatch(org, repoOrg) { 48 | continue 49 | } 50 | 51 | // catch all 52 | if 1 > matchLength { 53 | longestMatch = alias 54 | } 55 | 56 | if org.VCS == repoOrg.VCS && 2 > matchLength { 57 | matchLength = 1 58 | longestMatch = alias 59 | } 60 | 61 | if org.Name == repoOrg.Name { 62 | matchLength = 2 63 | longestMatch = alias 64 | } 65 | } 66 | 67 | return longestMatch, "" != longestMatch 68 | } 69 | 70 | func isMatch(org, repoOrg repository.Organization) bool { 71 | if org.VCS == "" && org.Name == "" { 72 | return true 73 | } 74 | 75 | if org.VCS != repoOrg.VCS { 76 | return false 77 | } 78 | 79 | return org.Name == "" || org.Name == repoOrg.Name 80 | } 81 | -------------------------------------------------------------------------------- /v2/cmd/config/scanner.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/viper" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | func newScanner(metadataClient voucher.MetadataClient) (scanner voucher.VulnerabilityScanner) { 11 | scannerName := viper.GetString("scanner") 12 | switch scannerName { 13 | case "gca", "g": 14 | log.Warningf("the %s option for `scanner` has been deprecated and will be removed in the future. Please use `metadata` instead.", scannerName) 15 | scanner = voucher.NewScanner(metadataClient) 16 | case "metadata": 17 | scanner = voucher.NewScanner(metadataClient) 18 | default: 19 | scanner = nil 20 | } 21 | 22 | if nil == scanner { 23 | log.Fatalf("not a valid scanner: %s", scannerName) 24 | } 25 | 26 | severity, err := voucher.StringToSeverity(viper.GetString("failon")) 27 | if nil != err { 28 | log.Fatal(err) 29 | } 30 | 31 | scanner.FailOn(severity) 32 | 33 | return 34 | } 35 | -------------------------------------------------------------------------------- /v2/cmd/config/secrets.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/Shopify/ejson" 9 | "github.com/spf13/viper" 10 | "go.mozilla.org/sops/v3/decrypt" 11 | 12 | "github.com/grafeas/voucher/v2/repository" 13 | "github.com/grafeas/voucher/v2/signer/pgp" 14 | ) 15 | 16 | // Secrets represents the format that the ejson configuration is structured 17 | // in. 18 | type Secrets struct { 19 | Keys map[string]string `json:"openpgpkeys"` 20 | RepositoryAuthentication repository.KeyRing `json:"repositories"` 21 | Datadog DatadogSecrets `json:"datadog"` 22 | } 23 | 24 | type DatadogSecrets struct { 25 | APIKey string `json:"api_key"` 26 | AppKey string `json:"app_key"` 27 | } 28 | 29 | // ReadSecrets reads from the ejson file and populates the passed interface. 30 | func ReadSecrets() (*Secrets, error) { 31 | decrypted, err := decryptSecrets() 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | var data Secrets 37 | if err := json.Unmarshal(decrypted, &data); err != nil { 38 | return nil, err 39 | } 40 | return &data, nil 41 | } 42 | 43 | func decryptSecrets() ([]byte, error) { 44 | ejDir := viper.GetString("ejson.dir") 45 | ejSecrets := viper.GetString("ejson.secrets") 46 | if ejDir != "" && ejSecrets != "" { 47 | return ejson.DecryptFile(ejSecrets, ejDir, "") 48 | } 49 | 50 | sops := viper.GetString("sops.file") 51 | if sops != "" { 52 | return decrypt.File(sops, "json") 53 | } 54 | 55 | return nil, fmt.Errorf("secrets not provided via ejson or sops") 56 | } 57 | 58 | // getPGPKeyRing uses the Command's configured ejson file to populate a 59 | // voucher.KeyRing. 60 | func (s *Secrets) getPGPKeyRing() (*pgp.KeyRing, error) { 61 | newKeyRing := pgp.NewKeyRing() 62 | 63 | for name, key := range s.Keys { 64 | err := pgp.AddKeyToKeyRingFromReader(newKeyRing, name, bytes.NewReader([]byte(key))) 65 | if nil != err { 66 | return nil, err 67 | } 68 | } 69 | 70 | return newKeyRing, nil 71 | } 72 | -------------------------------------------------------------------------------- /v2/cmd/config/secrets_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/grafeas/voucher/v2/repository" 13 | ) 14 | 15 | func TestNonExistantEjson(t *testing.T) { 16 | viper.Set("ejson.secrets", "../../../testdata/bad.ejson") 17 | viper.Set("ejson.dir", "../../../testdata/key") 18 | t.Cleanup(viper.Reset) 19 | 20 | _, err := ReadSecrets() 21 | require.Equal( 22 | t, 23 | err.Error(), 24 | "stat ../../../testdata/bad.ejson: no such file or directory", 25 | "did not fail appropriately, actual error is:", 26 | err, 27 | ) 28 | } 29 | 30 | func TestGetRepositoryKeyRing(t *testing.T) { 31 | viper.Set("ejson.secrets", "../../../testdata/test.ejson") 32 | viper.Set("ejson.dir", "../../../testdata/key") 33 | t.Cleanup(viper.Reset) 34 | 35 | data, err := ReadSecrets() 36 | require.NoError(t, err) 37 | assert.Equal(t, repository.KeyRing{ 38 | "organization-name": repository.Auth{ 39 | Token: "asdf1234", 40 | }, 41 | "organization2-name": repository.Auth{ 42 | Username: "testUser", 43 | Password: "testPassword", 44 | }, 45 | }, data.RepositoryAuthentication) 46 | } 47 | 48 | func TestGetRepositoryKeyRingNoEjson(t *testing.T) { 49 | viper.Set("ejson.secrets", "../../../testdata/test.ejson") 50 | viper.Set("ejson.dir", "../../../testdata/nokey") 51 | t.Cleanup(viper.Reset) 52 | 53 | data, err := ReadSecrets() 54 | require.Nil(t, data) 55 | assert.Error(t, err) 56 | } 57 | 58 | func TestGetPGPKeyRing(t *testing.T) { 59 | viper.Set("ejson.secrets", "../../../testdata/test.ejson") 60 | viper.Set("ejson.dir", "../../../testdata/key") 61 | t.Cleanup(viper.Reset) 62 | 63 | data, err := ReadSecrets() 64 | require.NoError(t, err) 65 | keyRing, err := data.getPGPKeyRing() 66 | require.NoError(t, err) 67 | assert.NotNil(t, keyRing) 68 | } 69 | 70 | func TestReadSops(t *testing.T) { 71 | viper.Set("sops.file", "../../../testdata/test.sops.json") 72 | t.Cleanup(viper.Reset) 73 | 74 | // Capture and restore GNUPGHOME variable 75 | existingHome := os.Getenv("GNUPGHOME") 76 | t.Cleanup(func() { os.Setenv("GNUPGHOME", existingHome) }) 77 | 78 | // Overwrite GNUPGHOME, shell to GPG to load the test private key 79 | testHome := t.TempDir() 80 | os.Setenv("GNUPGHOME", testHome) 81 | cmd := exec.Command("gpg", "--import", "../../../testdata/testkey.asc") 82 | err := cmd.Run() 83 | require.NoError(t, err) 84 | 85 | data, err := ReadSecrets() 86 | require.NoError(t, err) 87 | assert.Contains(t, data.Keys, "snakeoil") 88 | } 89 | -------------------------------------------------------------------------------- /v2/cmd/config/validrepos.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | ) 6 | 7 | func validRepos() []string { 8 | return viper.GetStringSlice("valid_repos") 9 | } 10 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | voucher "github.com/grafeas/voucher/v2" 10 | "github.com/grafeas/voucher/v2/client" 11 | ) 12 | 13 | type config struct { 14 | Server string 15 | Username string 16 | Password string 17 | Timeout int 18 | Check string 19 | Auth string 20 | } 21 | 22 | var defaultConfig = &config{} 23 | 24 | func getCheck() string { 25 | return defaultConfig.Check 26 | } 27 | 28 | func getVoucherClient(ctx context.Context) (voucher.Interface, error) { 29 | options := []client.Option{ 30 | client.WithUserAgent(fmt.Sprintf("voucher-client/%s", version)), 31 | } 32 | switch strings.ToLower(defaultConfig.Auth) { 33 | case "basic": 34 | options = append(options, client.WithBasicAuth(defaultConfig.Username, defaultConfig.Password)) 35 | case "idtoken": 36 | options = append(options, client.WithIDTokenAuth()) 37 | case "default-access-token": 38 | options = append(options, client.WithDefaultIDTokenAuth()) 39 | default: 40 | return nil, fmt.Errorf("invalid auth value: %q", defaultConfig.Auth) 41 | } 42 | return client.NewClientContext(ctx, defaultConfig.Server, options...) 43 | } 44 | 45 | func newContext() (context.Context, context.CancelFunc) { 46 | return context.WithTimeout(context.Background(), time.Duration(defaultConfig.Timeout)*time.Second) 47 | } 48 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/digest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/docker/distribution/reference" 8 | 9 | "github.com/grafeas/voucher/v2/docker" 10 | ) 11 | 12 | // getCanonicalReference gets the canonical image reference for the passed 13 | // image reference. If the passed reference is not already a canonical image 14 | // reference, this method will connect to the registry to get the current digest 15 | // and create the canonical reference from the original reference and that digest. 16 | // 17 | // This is because Binary Authorization only supports canonical image references, 18 | // as a non-canonical image reference could refer to multiple versions of the same 19 | // image (with different contents). 20 | func getCanonicalReference(client *http.Client, ref reference.Reference) (reference.Canonical, error) { 21 | if canonicalRef, ok := ref.(reference.Canonical); ok { 22 | return canonicalRef, nil 23 | } 24 | 25 | if taggedRef, ok := ref.(reference.NamedTagged); ok { 26 | imageDigest, err := docker.GetDigestFromTagged(client, taggedRef) 27 | if nil != err { 28 | return nil, fmt.Errorf("getting digest from tag failed: %s", err) 29 | } 30 | canonicalRef, err := reference.WithDigest(reference.TrimNamed(taggedRef), imageDigest) 31 | if nil != err { 32 | return nil, fmt.Errorf("making canonical reference failed: %s", err) 33 | } 34 | 35 | return canonicalRef, nil 36 | } 37 | return nil, fmt.Errorf("reference cannot be converted to a canonical reference") 38 | } 39 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/lookup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/docker/distribution/reference" 8 | 9 | voucher "github.com/grafeas/voucher/v2" 10 | "github.com/grafeas/voucher/v2/auth/google" 11 | ) 12 | 13 | // lookupCanonical looks up the canonical version of the passed image path. 14 | func lookupCanonical(ctx context.Context, image string) (reference.Canonical, error) { 15 | var ok bool 16 | var namedRef reference.Named 17 | 18 | ref, err := reference.Parse(image) 19 | if nil != err { 20 | return nil, fmt.Errorf("parsing image reference failed: %s", err) 21 | } 22 | 23 | if namedRef, ok = ref.(reference.Named); !ok { 24 | return nil, fmt.Errorf("couldn't get named version of reference: %s", err) 25 | } 26 | 27 | voucherClient, err := voucher.AuthToClient(ctx, google.NewAuth(), namedRef) 28 | if nil != err { 29 | return nil, fmt.Errorf("creating authenticated client failed: %s", err) 30 | } 31 | 32 | canonicalRef, err := getCanonicalReference(voucherClient, namedRef) 33 | if nil != err { 34 | err = fmt.Errorf("getting image digest failed: %s", err) 35 | } 36 | 37 | return canonicalRef, err 38 | } 39 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var ( 9 | version = "dev" 10 | commit = "none" 11 | date = "unknown" 12 | ) 13 | 14 | func main() { 15 | rootCmd.Version = version 16 | 17 | if err := rootCmd.Execute(); err != nil { 18 | fmt.Println(err) 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/output.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | // errorf prints a formatted string to standard error. 11 | func errorf(format string, v interface{}) { 12 | _, _ = fmt.Fprintf(os.Stderr, format+"\n", v) 13 | } 14 | 15 | // formatResponse returns the response as a string. 16 | func formatResponse(resp *voucher.Response) string { 17 | output := "" 18 | if resp.Success { 19 | fmt.Println("image is approved") 20 | } else { 21 | fmt.Println("image was rejected") 22 | } 23 | for _, result := range resp.Results { 24 | if result.Success { 25 | output += fmt.Sprintf(" ✓ passed %s", result.Name) 26 | if !result.Attested { 27 | output += ", but wasn't attested" 28 | } 29 | } else { 30 | output += fmt.Sprintf(" ✗ failed %s", result.Name) 31 | } 32 | 33 | if "" != result.Err { 34 | output += fmt.Sprintf(", err: %s", result.Err) 35 | } 36 | output += "\n" 37 | } 38 | 39 | return output 40 | } 41 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/submit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/docker/distribution/reference" 10 | 11 | voucher "github.com/grafeas/voucher/v2" 12 | ) 13 | 14 | var errImageCheckFailed = errors.New("image failed to pass required check(s)") 15 | 16 | // check checks the passed image to the voucher server. 17 | func check(ctx context.Context, client voucher.Interface, check string, canonicalRef reference.Canonical) error { 18 | fmt.Printf("Submitting image to Voucher: %s\n", canonicalRef.String()) 19 | 20 | voucherResp, err := client.Check(ctx, check, canonicalRef) 21 | if nil != err { 22 | return fmt.Errorf("signing image failed: %s", err) 23 | } 24 | 25 | fmt.Println(formatResponse(&voucherResp)) 26 | 27 | if !voucherResp.Success { 28 | return errImageCheckFailed 29 | } 30 | 31 | return nil 32 | } 33 | 34 | // LookupAndCheck looks up the passed image, and checks it with the Voucher 35 | // server. 36 | func LookupAndCheck(args []string) { 37 | var err error 38 | 39 | ctx, cancel := newContext() 40 | defer cancel() 41 | 42 | client, err := getVoucherClient(ctx) 43 | if nil != err { 44 | errorf("creating client failed: %s", err) 45 | os.Exit(1) 46 | } 47 | 48 | canonicalRef, err := lookupCanonical(ctx, args[0]) 49 | if nil != err { 50 | errorf("getting canonical reference failed: %s", err) 51 | os.Exit(1) 52 | } 53 | 54 | err = check(ctx, client, getCheck(), canonicalRef) 55 | if nil != err { 56 | errorf("checking image with voucher failed: %s", err) 57 | os.Exit(1) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /v2/cmd/voucher_client/verify.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/docker/distribution/reference" 9 | 10 | voucher "github.com/grafeas/voucher/v2" 11 | ) 12 | 13 | // verifyImage submits the passed image to the voucher server for verification. 14 | func verifyImage(ctx context.Context, client voucher.Interface, check string, canonicalRef reference.Canonical) error { 15 | fmt.Printf("Verifying image with Voucher: %s\n", canonicalRef.String()) 16 | 17 | voucherResp, err := client.Verify(ctx, check, canonicalRef) 18 | if nil != err { 19 | return fmt.Errorf("verifying image failed: %s", err) 20 | } 21 | 22 | fmt.Println(formatResponse(&voucherResp)) 23 | 24 | if !voucherResp.Success { 25 | return errImageCheckFailed 26 | } 27 | 28 | return nil 29 | } 30 | 31 | // LookupAndVerify looks up the passed image, and submits it with the Voucher server. 32 | func LookupAndVerify(args []string) { 33 | var err error 34 | 35 | ctx, cancel := newContext() 36 | defer cancel() 37 | 38 | client, err := getVoucherClient(ctx) 39 | if nil != err { 40 | errorf("creating client failed: %s", err) 41 | os.Exit(1) 42 | } 43 | 44 | canonicalRef, err := lookupCanonical(ctx, args[0]) 45 | if nil != err { 46 | errorf("getting canonical reference failed: %s", err) 47 | os.Exit(1) 48 | } 49 | 50 | err = verifyImage(ctx, client, getCheck(), canonicalRef) 51 | if nil != err { 52 | errorf("verifying image with voucher failed: %s", err) 53 | os.Exit(1) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /v2/cmd/voucher_server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var ( 9 | version = "dev" 10 | commit = "none" 11 | date = "unknown" 12 | ) 13 | 14 | func main() { 15 | serverCmd.Version = version 16 | 17 | if err := serverCmd.Execute(); err != nil { 18 | fmt.Println(err) 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /v2/cmd/voucher_server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/grafeas/voucher/v2/cmd/config" 11 | "github.com/grafeas/voucher/v2/server" 12 | ) 13 | 14 | var serverCmd = &cobra.Command{ 15 | Use: "server", 16 | Short: "Runs the server", 17 | Long: `Run the go server on the specified port 18 | use --port= to specify the port you want the server to run on`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | serverConfig := server.Config{ 21 | Port: viper.GetInt("server.port"), 22 | Timeout: viper.GetInt("server.timeout"), 23 | RequireAuth: viper.GetBool("server.require_auth"), 24 | Username: viper.GetString("server.username"), 25 | PassHash: viper.GetString("server.password"), 26 | } 27 | 28 | secrets, err := config.ReadSecrets() 29 | if err != nil { 30 | log.Printf("Error loading EJSON file, no secrets loaded: %v", err) 31 | } 32 | 33 | metricsClient, err := config.MetricsClient(secrets) 34 | if err != nil { 35 | log.Printf("Error configuring metrics client: %v", err) 36 | } else if closer, ok := metricsClient.(io.Closer); ok { 37 | defer closer.Close() 38 | } 39 | 40 | config.RegisterDynamicChecks() 41 | 42 | if config.IsCloudRun() { 43 | serverConfig.RequireAuth = false 44 | } 45 | 46 | voucherServer := server.NewServer(&serverConfig, secrets, metricsClient) 47 | 48 | for groupName, checks := range config.GetRequiredChecksFromConfig() { 49 | voucherServer.SetCheckGroup(groupName, checks) 50 | } 51 | 52 | voucherServer.Serve() 53 | }, 54 | } 55 | 56 | func init() { 57 | cobra.OnInitialize(config.InitConfig) 58 | serverCmd.Flags().IntP("port", "p", 8000, "port on which the server will listen") 59 | viper.BindPFlag("server.port", serverCmd.Flags().Lookup("port")) 60 | serverCmd.Flags().StringVarP(&config.FileName, "config", "c", "", "path to config") 61 | serverCmd.Flags().IntP("timeout", "", 240, "number of seconds that should be dedicated to a Voucher call") 62 | viper.BindPFlag("server.timeout", serverCmd.Flags().Lookup("timeout")) 63 | } 64 | -------------------------------------------------------------------------------- /v2/cmd/voucher_subscriber/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var ( 9 | version = "dev" 10 | commit = "none" 11 | date = "unknown" 12 | ) 13 | 14 | func main() { 15 | subscriberCmd.Version = version 16 | 17 | if err := subscriberCmd.Execute(); err != nil { 18 | fmt.Println(err) 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /v2/containeranalysis/attestation.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "strings" 5 | 6 | grafeas "google.golang.org/genproto/googleapis/grafeas/v1" 7 | 8 | voucher "github.com/grafeas/voucher/v2" 9 | ) 10 | 11 | // OccurrenceToAttestation converts an Occurrence to a Attestation 12 | func OccurrenceToAttestation(checkName string, occ *grafeas.Occurrence) voucher.SignedAttestation { 13 | signedAttestation := voucher.SignedAttestation{ 14 | Attestation: voucher.Attestation{ 15 | CheckName: checkName, 16 | }, 17 | } 18 | 19 | attestationDetails := occ.GetAttestation() 20 | 21 | signedAttestation.Body = string(attestationDetails.GetSerializedPayload()) 22 | 23 | return signedAttestation 24 | } 25 | 26 | func getCheckNameFromNoteName(project, value string) string { 27 | projectPath := projectPath(project) + "/notes/" 28 | if strings.HasPrefix(value, projectPath) { 29 | result := strings.Replace( 30 | value, 31 | projectPath, 32 | "", 33 | -1, 34 | ) 35 | if result != "" { 36 | return result 37 | } 38 | } 39 | return "unknown" 40 | } 41 | -------------------------------------------------------------------------------- /v2/containeranalysis/attestation_test.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestGetCheckNameFromNoteName(t *testing.T) { 9 | testValues := []struct { 10 | input string 11 | expected string 12 | }{ 13 | { 14 | input: "projects/testproject/notes/diy", 15 | expected: "diy", 16 | }, 17 | { 18 | input: "projects/testproject/notes/", 19 | expected: "unknown", 20 | }, 21 | { 22 | input: "", 23 | expected: "unknown", 24 | }, 25 | } 26 | 27 | for _, test := range testValues { 28 | output := getCheckNameFromNoteName("testproject", test.input) 29 | assert.Equal(t, test.expected, output) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /v2/containeranalysis/build.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "github.com/grafeas/voucher/v2/repository" 5 | grafeas "google.golang.org/genproto/googleapis/grafeas/v1" 6 | ) 7 | 8 | // OccurrenceToBuildDetail converts an Occurrence to a BuildDetail 9 | func OccurrenceToBuildDetail(occ *grafeas.Occurrence) (detail repository.BuildDetail) { 10 | buildProvenance := occ.GetBuild().GetProvenance() 11 | 12 | detail.ProjectID = buildProvenance.GetProjectId() 13 | detail.BuildCreator = buildProvenance.GetCreator() 14 | detail.BuildURL = buildProvenance.GetLogsUri() 15 | detail.RepositoryURL = buildProvenance.GetSourceProvenance().GetContext().GetGit().GetUrl() 16 | detail.Commit = buildProvenance.GetSourceProvenance().GetContext().GetGit().GetRevisionId() 17 | 18 | buildArtifacts := buildProvenance.GetBuiltArtifacts() 19 | 20 | detail.Artifacts = make([]repository.BuildArtifact, 0, len(buildArtifacts)) 21 | 22 | for _, artifact := range buildArtifacts { 23 | detail.Artifacts = append(detail.Artifacts, repository.BuildArtifact{ 24 | ID: artifact.Id, 25 | Checksum: artifact.Checksum, 26 | }) 27 | } 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /v2/containeranalysis/containeranalysis.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/docker/distribution/reference" 7 | grafeas "google.golang.org/genproto/googleapis/grafeas/v1" 8 | ) 9 | 10 | var errNoOccurrences = errors.New("no occurrences returned for image") 11 | var errDiscoveriesUnfinished = errors.New("discoveries have not finished processing") 12 | 13 | func resourceURL(reference reference.Reference) string { 14 | return "resourceUrl=\"https://" + reference.String() + "\"" 15 | } 16 | 17 | func projectPath(project string) string { 18 | return "projects/" + project 19 | } 20 | 21 | func kindFilterStr(reference reference.Reference, kind grafeas.NoteKind) string { 22 | return resourceURL(reference) + " AND kind=\"" + kind.String() + "\"" 23 | } 24 | -------------------------------------------------------------------------------- /v2/containeranalysis/containeranalysis_test.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | voucher "github.com/grafeas/voucher/v2" 10 | ) 11 | 12 | const testImageName = "gcr.io/alpine/alpine@sha256:297524b7375fbf09b3784f0bbd9cb2505700dd05e03ce5f5e6d262bf2f5ac51c" 13 | 14 | const testResourceAddress = "resourceUrl=\"https://" + testImageName + "\"" 15 | 16 | func TestGrafeasHelperFunctions(t *testing.T) { 17 | imageData, err := voucher.NewImageData(testImageName) 18 | require.NoError(t, err) 19 | assert.Equal(t, resourceURL(imageData), testResourceAddress) 20 | } 21 | -------------------------------------------------------------------------------- /v2/containeranalysis/create.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "github.com/docker/distribution/reference" 5 | grafeas "google.golang.org/genproto/googleapis/grafeas/v1" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | func newOccurrenceAttestation(image reference.Canonical, attestation voucher.SignedAttestation, binauthProject string) *grafeas.CreateOccurrenceRequest { 11 | newAttestation := grafeas.AttestationOccurrence{ 12 | SerializedPayload: []byte(attestation.Body), 13 | Signatures: []*grafeas.Signature{ 14 | { 15 | Signature: []byte(attestation.Signature), 16 | PublicKeyId: attestation.KeyID, 17 | }, 18 | }, 19 | } 20 | 21 | binauthProjectPath := projectPath(binauthProject) 22 | noteName := binauthProjectPath + "/notes/" + attestation.CheckName 23 | 24 | request := &grafeas.CreateOccurrenceRequest{ 25 | Parent: binauthProjectPath, 26 | Occurrence: &grafeas.Occurrence{ 27 | NoteName: noteName, 28 | ResourceUri: "https://" + image.Name() + "@" + image.Digest().String(), 29 | Details: &grafeas.Occurrence_Attestation{ 30 | Attestation: &newAttestation, 31 | }, 32 | }, 33 | } 34 | 35 | return request 36 | } 37 | -------------------------------------------------------------------------------- /v2/containeranalysis/error.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "google.golang.org/grpc/codes" 5 | "google.golang.org/grpc/status" 6 | ) 7 | 8 | // isAttestationExistsErr returns true if the passed Error is an "AlreadyExists" gRPC error. 9 | func isAttestionExistsErr(err error) bool { 10 | if nil == err { 11 | return false 12 | } 13 | 14 | return (codes.AlreadyExists == status.Code(err)) 15 | } 16 | -------------------------------------------------------------------------------- /v2/containeranalysis/types.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import voucher "github.com/grafeas/voucher/v2" 4 | 5 | // DiscoveryType is a Grafeas specific type which refers to MetadataItems containing metadata discovery status. 6 | const DiscoveryType voucher.MetadataType = "discovery" 7 | 8 | // PackageType is a Grafeas specific type which refers to MetadataItems containing package information. 9 | const PackageType voucher.MetadataType = "package" 10 | 11 | // ImageType is a Grafeas specific type which refers to MetadataItems containing Image information. 12 | const ImageType voucher.MetadataType = "image" 13 | 14 | // DeploymentType is a Grafeas specific type which refers to MetadataItems containing deployment data. 15 | const DeploymentType voucher.MetadataType = "deployment" 16 | -------------------------------------------------------------------------------- /v2/containeranalysis/vulnerability.go: -------------------------------------------------------------------------------- 1 | package containeranalysis 2 | 3 | import ( 4 | "strings" 5 | 6 | grafeas "google.golang.org/genproto/googleapis/grafeas/v1" 7 | 8 | voucher "github.com/grafeas/voucher/v2" 9 | ) 10 | 11 | // vulProject is the project that Google's Container Analysis writes vulnerability 12 | // occurrences to. 13 | const vulProject = "projects/goog-vulnz/notes/" 14 | 15 | // getSeverity translates the Google Container Analysis Severity to a Voucher Severity. 16 | func getSeverity(severity string) voucher.Severity { 17 | switch severity { 18 | case "MINIMAL": 19 | return voucher.NegligibleSeverity 20 | case "LOW": 21 | return voucher.LowSeverity 22 | case "MEDIUM": 23 | return voucher.MediumSeverity 24 | case "HIGH": 25 | return voucher.HighSeverity 26 | case "CRITICAL": 27 | return voucher.CriticalSeverity 28 | } 29 | 30 | return voucher.UnknownSeverity 31 | } 32 | 33 | // OccurrenceToVulnerability converts an Occurrence to a Vulnerability. 34 | func OccurrenceToVulnerability(occ *grafeas.Occurrence) voucher.Vulnerability { 35 | vulnDetails := occ.GetDetails().(*grafeas.Occurrence_Vulnerability).Vulnerability 36 | 37 | return voucher.Vulnerability{ 38 | Name: strings.Replace(occ.GetNoteName(), vulProject, "", 1), 39 | Severity: getSeverity(grafeas.Severity_name[int32(vulnDetails.EffectiveSeverity)]), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /v2/docker/call.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // responseToError converts the body of a response to an error. 10 | func responseToError(resp *http.Response) error { 11 | b, err := io.ReadAll(resp.Body) 12 | if nil == err { 13 | err = errors.New("failed to load resource with status \"" + resp.Status + "\": " + string(b)) 14 | } 15 | 16 | return errors.New("failed to load resource with error: " + err.Error()) 17 | } 18 | -------------------------------------------------------------------------------- /v2/docker/config.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/docker/distribution/reference" 8 | dockerTypes "github.com/docker/docker/api/types" 9 | 10 | "github.com/grafeas/voucher/v2/docker/ocischema" 11 | "github.com/grafeas/voucher/v2/docker/schema1" 12 | "github.com/grafeas/voucher/v2/docker/schema2" 13 | ) 14 | 15 | // RequestImageConfig requests an image configuration from the server, based on the passed 16 | // reference. Returns an ImageConfig or an error. 17 | func RequestImageConfig(client *http.Client, ref reference.Canonical) (ImageConfig, error) { 18 | manifest, err := RequestManifest(client, ref) 19 | if nil != err { 20 | return nil, err 21 | } 22 | 23 | var config *dockerTypes.ExecConfig 24 | 25 | switch { 26 | case schema1.IsManifest(manifest): 27 | config, err = schema1.RequestConfig(client, ref, manifest) 28 | case schema2.IsManifest(manifest): 29 | config, err = schema2.RequestConfig(client, ref, manifest) 30 | case ocischema.IsManifest(manifest): 31 | config, err = ocischema.RequestConfig(client, ref, manifest) 32 | default: 33 | err = errors.New("image does not have any configuration") 34 | } 35 | 36 | if nil != err { 37 | return nil, NewConfigError(err) 38 | } 39 | 40 | return &imageConfig{ 41 | *config, 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /v2/docker/config_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | vtesting "github.com/grafeas/voucher/v2/testing" 9 | ) 10 | 11 | func TestRequestConfig(t *testing.T) { 12 | ref := vtesting.NewTestReference(t) 13 | 14 | client, server := vtesting.PrepareDockerTest(t, ref) 15 | defer server.Close() 16 | 17 | config, err := RequestImageConfig(client, ref) 18 | require.NoError(t, err) 19 | require.False(t, config.RunsAsRoot()) 20 | } 21 | -------------------------------------------------------------------------------- /v2/docker/digest_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/distribution/reference" 7 | digest "github.com/opencontainers/go-digest" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | vtesting "github.com/grafeas/voucher/v2/testing" 12 | ) 13 | 14 | func TestGetDigestFromTagged(t *testing.T) { 15 | ref := vtesting.NewTestReference(t) 16 | 17 | taggedRef, err := reference.WithTag(ref, "latest") 18 | require.NoErrorf(t, err, "failed to get tagged reference: %s", err) 19 | 20 | client, server := vtesting.PrepareDockerTest(t, taggedRef) 21 | defer server.Close() 22 | 23 | imageDigest, err := GetDigestFromTagged(client, taggedRef) 24 | require.NoErrorf(t, err, "failed to get digest reference: %s", err) 25 | 26 | assert.Equal(t, digest.Digest("sha256:b148c8af52ba402ed7dd98d73f5a41836ece508d1f4704b274562ac0c9b3b7da"), imageDigest) 27 | } 28 | 29 | func TestGetBadDigestFromTagged(t *testing.T) { 30 | ref := vtesting.NewBadTestReference(t) 31 | 32 | taggedRef, err := reference.WithTag(ref, "latest") 33 | require.NoErrorf(t, err, "failed to get tagged reference: %s", err) 34 | 35 | client, server := vtesting.PrepareDockerTest(t, taggedRef) 36 | defer server.Close() 37 | 38 | imageDigest, err := GetDigestFromTagged(client, taggedRef) 39 | assert.NotNilf(t, err, "should have failed to get digest, but didn't") 40 | assert.Equal(t, digest.Digest(""), imageDigest) 41 | assert.Contains(t, err.Error(), "failed to load resource with status \"404 Not Found\":") 42 | } 43 | 44 | func TestRequestV1Digest(t *testing.T) { 45 | ref := vtesting.NewTestSchema1SignedReference(t) 46 | 47 | taggedRef, err := reference.WithTag(ref, "latest") 48 | require.NoErrorf(t, err, "failed to get tagged reference: %s", err) 49 | 50 | client, server := vtesting.PrepareDockerTest(t, taggedRef) 51 | defer server.Close() 52 | 53 | imageDigest, err := GetDigestFromTagged(client, taggedRef) 54 | require.NoErrorf(t, err, "failed to get digest reference: %s", err) 55 | 56 | assert.Equal(t, digest.Digest("sha256:18e6e7971438ab792d13563dcd8972acf4445bc0dcfdff84a6374d63a9c3ed62"), imageDigest) 57 | } 58 | -------------------------------------------------------------------------------- /v2/docker/docker_error.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "fmt" 4 | 5 | const ( 6 | manifestType = "manifest" 7 | configType = "config" 8 | ) 9 | 10 | // APIError is a generic error structure representing a docker API call 11 | // error. It tracks the type of request that failed, and either wraps an error 12 | // or contains the body of an API call. 13 | type APIError struct { 14 | callType string 15 | requestStatus string 16 | requestBody string 17 | err error 18 | } 19 | 20 | // Error returns the docker API error as a string. 21 | func (err *APIError) Error() string { 22 | if err.requestBody != "" { 23 | return fmt.Sprintf("failed to load %s with status %s: \"%s\"", err.callType, err.requestStatus, err.requestBody) 24 | } 25 | return fmt.Sprintf("failed to load %s: %s", err.callType, err.err) 26 | } 27 | 28 | // NewManifestError creates a new APIError specific to docker manifest requests. 29 | // This version wraps the passed error. 30 | func NewManifestError(err error) error { 31 | return &APIError{ 32 | callType: manifestType, 33 | err: err, 34 | } 35 | } 36 | 37 | // NewManifestErrorWithRequest creates a new APIError specific to docker 38 | // manifest requests. This version wraps the passed HTTP response. 39 | func NewManifestErrorWithRequest(status string, b []byte) error { 40 | return &APIError{ 41 | callType: manifestType, 42 | requestStatus: status, 43 | requestBody: string(b), 44 | } 45 | } 46 | 47 | // NewConfigError creates a new APIError specific to docker config requests. 48 | // This version wraps the passed error. 49 | func NewConfigError(err error) error { 50 | return &APIError{ 51 | callType: configType, 52 | err: err, 53 | } 54 | } 55 | 56 | // NewConfigErrorWithRequest creates a new APIError specific to docker config 57 | // requests. This version wraps the passed HTTP response. 58 | func NewConfigErrorWithRequest(status string, b []byte) error { 59 | return &APIError{ 60 | callType: configType, 61 | requestStatus: status, 62 | requestBody: string(b), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /v2/docker/imageconfig.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | dockerTypes "github.com/docker/docker/api/types" 5 | ) 6 | 7 | // ImageConfig represents an Docker image configuration. This presently just 8 | // allows us to verify if an image runs as root or not. 9 | type ImageConfig interface { 10 | // RunsAsRoot returns true if the passed image will run as the root user. 11 | RunsAsRoot() bool 12 | } 13 | 14 | type imageConfig struct { 15 | dockerTypes.ExecConfig 16 | } 17 | 18 | // RunsAsRoot returns true if the image will run as the root user. 19 | func (config *imageConfig) RunsAsRoot() bool { 20 | user := config.User 21 | 22 | return ("" == user || "root" == user || "0:0" == user || "0" == user) 23 | } 24 | -------------------------------------------------------------------------------- /v2/docker/manifest.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/docker/distribution" 7 | "github.com/docker/distribution/manifest/manifestlist" 8 | "github.com/docker/distribution/manifest/ocischema" 9 | "github.com/docker/distribution/manifest/schema1" 10 | "github.com/docker/distribution/manifest/schema2" 11 | "github.com/docker/distribution/reference" 12 | 13 | "github.com/grafeas/voucher/v2/docker/uri" 14 | ) 15 | 16 | // RequestManifest requests an Manifest for the passed canonical image reference (an image URL 17 | // with a digest specifying the built image). Returns a schema2.Manifest, or an error if 18 | // there's an issue. 19 | func RequestManifest(client *http.Client, ref reference.Canonical) (distribution.Manifest, error) { 20 | var manifest distribution.Manifest 21 | 22 | request, err := http.NewRequest(http.MethodGet, uri.GetDigestManifestURI(ref), nil) 23 | if nil != err { 24 | return nil, err 25 | } 26 | 27 | request.Header.Add("Accept", ocischema.SchemaVersion.MediaType) 28 | request.Header.Add("Accept", manifestlist.OCISchemaVersion.MediaType) 29 | request.Header.Add("Accept", manifestlist.MediaTypeManifestList) 30 | request.Header.Add("Accept", schema2.MediaTypeManifest) 31 | request.Header.Add("Accept", schema1.MediaTypeManifest) 32 | request.Header.Add("Accept", schema1.MediaTypeSignedManifest) 33 | 34 | manifest, err = getDockerManifest(client, request) 35 | if nil != err { 36 | return nil, err 37 | } 38 | 39 | return manifest, nil 40 | } 41 | -------------------------------------------------------------------------------- /v2/docker/manifest_request.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/docker/distribution" 8 | ) 9 | 10 | // getDockerManifest executes an API call to Docker using the passed http.Client, and unmarshals 11 | // the resulting data into the passed interface, or returns an error if there's an issue. 12 | func getDockerManifest(client *http.Client, request *http.Request) (distribution.Manifest, error) { 13 | resp, err := client.Do(request) 14 | if nil != err { 15 | return nil, NewManifestError(err) 16 | } 17 | 18 | defer resp.Body.Close() 19 | 20 | b, err := io.ReadAll(resp.Body) 21 | if nil != err { 22 | return nil, NewManifestError(err) 23 | } 24 | 25 | if resp.StatusCode >= 300 { 26 | return nil, NewManifestErrorWithRequest(resp.Status, b) 27 | } 28 | 29 | contentType := resp.Header.Get("Content-Type") 30 | if !isValidManifest(contentType) { 31 | return nil, NewManifestErrorWithRequest(resp.Status, b) 32 | } 33 | 34 | manifest, _, err := distribution.UnmarshalManifest(contentType, b) 35 | if nil != err { 36 | return nil, NewManifestError(err) 37 | } 38 | 39 | return manifest, nil 40 | } 41 | 42 | // isValidManifest ensures that we don't try to unmarshal an invalid manifest. 43 | func isValidManifest(contentType string) bool { 44 | for _, mediaType := range distribution.ManifestMediaTypes() { 45 | if mediaType == contentType { 46 | return true 47 | } 48 | } 49 | 50 | return false 51 | } 52 | -------------------------------------------------------------------------------- /v2/docker/manifest_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/grafeas/voucher/v2/docker/schema2" 10 | vtesting "github.com/grafeas/voucher/v2/testing" 11 | ) 12 | 13 | func TestRequestManifest(t *testing.T) { 14 | ref := vtesting.NewTestReference(t) 15 | 16 | client, server := vtesting.PrepareDockerTest(t, ref) 17 | defer server.Close() 18 | 19 | manifest, err := RequestManifest(client, ref) 20 | require.NoError(t, err) 21 | 22 | schema2Manifest, err := schema2.ToManifest(client, ref, manifest) 23 | require.NoError(t, err) 24 | 25 | assert.Equal( 26 | t, 27 | vtesting.NewTestManifest().Manifest, 28 | schema2Manifest, 29 | ) 30 | } 31 | 32 | func TestRequestBadManifest(t *testing.T) { 33 | ref := vtesting.NewBadTestReference(t) 34 | 35 | client, server := vtesting.PrepareDockerTest(t, ref) 36 | defer server.Close() 37 | 38 | _, err := RequestManifest(client, ref) 39 | require.NotNilf(t, err, "should have failed to get manifest, but didn't") 40 | assert.Equal(t, 41 | NewManifestErrorWithRequest("404 Not Found", []byte("image doesn't exist\n")), 42 | err, 43 | ) 44 | } 45 | 46 | func TestRateLimitedBadManifest(t *testing.T) { 47 | ref := vtesting.NewRateLimitedTestReference(t) 48 | 49 | client, server := vtesting.PrepareDockerTest(t, ref) 50 | defer server.Close() 51 | 52 | _, err := RequestManifest(client, ref) 53 | assert.NotNilf(t, err, "should have failed to get manifest, but didn't") 54 | assert.Equal(t, 55 | NewManifestErrorWithRequest("200 OK", []byte(vtesting.RateLimitOutput+"\n")), 56 | err, 57 | ) 58 | } 59 | 60 | func TestRequestManifestList(t *testing.T) { 61 | ref := vtesting.NewTestManifestListReference(t) 62 | 63 | client, server := vtesting.PrepareDockerTest(t, ref) 64 | defer server.Close() 65 | 66 | manifest, err := RequestManifest(client, ref) 67 | require.NoError(t, err) 68 | 69 | schema2Manifest, err := schema2.ToManifest(client, ref, manifest) 70 | require.NoError(t, err) 71 | 72 | assert.Equal( 73 | t, 74 | vtesting.NewTestManifest().Manifest, 75 | schema2Manifest, 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /v2/docker/ocischema/config.go: -------------------------------------------------------------------------------- 1 | package ocischema 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/docker/distribution" 10 | "github.com/docker/distribution/reference" 11 | dockerTypes "github.com/docker/docker/api/types" 12 | 13 | "github.com/grafeas/voucher/v2/docker/uri" 14 | ) 15 | 16 | type v2Blob struct { 17 | Config dockerTypes.ExecConfig `json:"container_config"` 18 | } 19 | 20 | // RequestConfig requests an image configuration from the server, based on the passed digest. 21 | // Returns an ImageConfig or an error. 22 | func RequestConfig(client *http.Client, ref reference.Canonical, manifest distribution.Manifest) (*dockerTypes.ExecConfig, error) { 23 | if !IsManifest(manifest) { 24 | return nil, errors.New("cannot request oci schema2 config for non-oci schema2 manifest") 25 | } 26 | 27 | v2Manifest, err := ToManifest(client, ref, manifest) 28 | if err != nil { 29 | return nil, fmt.Errorf("fetching manifest: %w", err) 30 | } 31 | 32 | var wrapper v2Blob 33 | 34 | request, err := http.NewRequest( 35 | http.MethodGet, 36 | uri.GetBlobURI(ref, v2Manifest.Config.Digest), 37 | nil, 38 | ) 39 | if nil != err { 40 | return nil, err 41 | } 42 | 43 | request.Header.Add("Accept", v2Manifest.Config.MediaType) 44 | 45 | resp, err := client.Do(request) 46 | if nil != err { 47 | return nil, err 48 | } 49 | defer resp.Body.Close() 50 | 51 | if resp.StatusCode < 300 { 52 | err = json.NewDecoder(resp.Body).Decode(&wrapper) 53 | if nil == err { 54 | return &wrapper.Config, nil 55 | } 56 | } 57 | 58 | return nil, err 59 | } 60 | -------------------------------------------------------------------------------- /v2/docker/ocischema/config_test.go: -------------------------------------------------------------------------------- 1 | package ocischema 2 | 3 | import ( 4 | "testing" 5 | 6 | dockerTypes "github.com/docker/docker/api/types" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | vtesting "github.com/grafeas/voucher/v2/testing" 11 | ) 12 | 13 | func TestRequestConfig(t *testing.T) { 14 | ref := vtesting.NewTestOCIReference(t) 15 | 16 | manifest := vtesting.NewTestOCIManifest() 17 | 18 | client, server := vtesting.PrepareDockerTest(t, ref) 19 | defer server.Close() 20 | 21 | config, err := RequestConfig(client, ref, manifest) 22 | require.NoError(t, err, "failed to get config: %s", err) 23 | 24 | expectedConfig := &dockerTypes.ExecConfig{ 25 | User: "nobody", 26 | } 27 | 28 | assert.Equal(t, expectedConfig, config) 29 | } 30 | -------------------------------------------------------------------------------- /v2/docker/ocischema/manifest_test.go: -------------------------------------------------------------------------------- 1 | package ocischema 2 | 3 | import ( 4 | "testing" 5 | 6 | vtesting "github.com/grafeas/voucher/v2/testing" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestToManifest(t *testing.T) { 12 | newManifest := vtesting.NewTestOCIManifest() 13 | manifest, err := ToManifest(nil, nil, newManifest) 14 | require.NoError(t, err) 15 | assert.NotNil(t, manifest) 16 | } 17 | -------------------------------------------------------------------------------- /v2/docker/schema1/config.go: -------------------------------------------------------------------------------- 1 | package schema1 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/docker/distribution" 9 | "github.com/docker/distribution/reference" 10 | dockerTypes "github.com/docker/docker/api/types" 11 | ) 12 | 13 | type v1Blob struct { 14 | Config dockerTypes.ExecConfig `json:"config"` 15 | } 16 | 17 | // RequestConfig retrieves the manifest from the associated configuration. 18 | // Unlike in v2 manifests, v1 manifests have the configuration stored in the 19 | // history, so we can safely ignore the http.Client passed to this function. 20 | func RequestConfig(_ *http.Client, _ reference.Canonical, manifest distribution.Manifest) (*dockerTypes.ExecConfig, error) { 21 | if !IsManifest(manifest) { 22 | return nil, errors.New("cannot request schema1 config for non-schema1 manifest") 23 | } 24 | 25 | v1Manifest := ToManifest(manifest) 26 | 27 | if len(v1Manifest.History) < 1 { 28 | return nil, errors.New("no history in manifest") 29 | } 30 | 31 | configBlob := v1Blob{} 32 | 33 | err := json.Unmarshal([]byte(v1Manifest.History[0].V1Compatibility), &configBlob) 34 | if nil != err { 35 | return nil, err 36 | } 37 | 38 | return &configBlob.Config, nil 39 | } 40 | -------------------------------------------------------------------------------- /v2/docker/schema1/config_test.go: -------------------------------------------------------------------------------- 1 | package schema1 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | vtesting "github.com/grafeas/voucher/v2/testing" 10 | ) 11 | 12 | func TestConfigFromManifest(t *testing.T) { 13 | pk := vtesting.NewPrivateKey() 14 | newManifest := vtesting.NewTestSchema1SignedManifest(pk) 15 | 16 | // we can pass nil as the http.Client because schema1's config is stored in 17 | // the history fields. It's super weird. 18 | config, err := RequestConfig(nil, nil, newManifest) 19 | require.NoError(t, err) 20 | assert.NotNil(t, config) 21 | assert.Equal(t, "nobody", config.User) 22 | } 23 | -------------------------------------------------------------------------------- /v2/docker/schema1/manifest.go: -------------------------------------------------------------------------------- 1 | package schema1 2 | 3 | import ( 4 | "github.com/docker/distribution" 5 | v1 "github.com/docker/distribution/manifest/schema1" 6 | ) 7 | 8 | // IsManifest returns true if the passed manifest is a schema1 manifest. 9 | func IsManifest(m distribution.Manifest) bool { 10 | _, ok := m.(*v1.SignedManifest) 11 | return ok 12 | } 13 | 14 | // ToManifest casts a distribution.Manifest to a schema1.Manifest. It panics 15 | // if it passed anything other than a schema1.SignedManifest. 16 | func ToManifest(manifest distribution.Manifest) *v1.SignedManifest { 17 | signedManifest, ok := manifest.(*v1.SignedManifest) 18 | if !ok { 19 | panic("schema1.ToManifest was passed a non-schema1.SignedManifest") 20 | } 21 | 22 | return signedManifest 23 | } 24 | -------------------------------------------------------------------------------- /v2/docker/schema1/manifest_test.go: -------------------------------------------------------------------------------- 1 | package schema1 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | vtesting "github.com/grafeas/voucher/v2/testing" 9 | ) 10 | 11 | func TestToManifest(t *testing.T) { 12 | pk := vtesting.NewPrivateKey() 13 | newManifest := vtesting.NewTestSchema1SignedManifest(pk) 14 | 15 | manifest := ToManifest(newManifest) 16 | assert.NotNil(t, manifest) 17 | } 18 | -------------------------------------------------------------------------------- /v2/docker/schema2/config.go: -------------------------------------------------------------------------------- 1 | package schema2 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/docker/distribution" 10 | "github.com/docker/distribution/reference" 11 | dockerTypes "github.com/docker/docker/api/types" 12 | 13 | "github.com/grafeas/voucher/v2/docker/uri" 14 | ) 15 | 16 | type v2Blob struct { 17 | Config dockerTypes.ExecConfig `json:"container_config"` 18 | } 19 | 20 | // RequestConfig requests an image configuration from the server, based on the passed digest. 21 | // Returns an ImageConfig or an error. 22 | func RequestConfig(client *http.Client, ref reference.Canonical, manifest distribution.Manifest) (*dockerTypes.ExecConfig, error) { 23 | if !IsManifest(manifest) { 24 | return nil, errors.New("cannot request schema2 config for non-schema2 manifest") 25 | } 26 | 27 | v2Manifest, err := ToManifest(client, ref, manifest) 28 | if err != nil { 29 | return nil, fmt.Errorf("fetching manifest: %w", err) 30 | } 31 | 32 | var wrapper v2Blob 33 | 34 | request, err := http.NewRequest( 35 | http.MethodGet, 36 | uri.GetBlobURI(ref, v2Manifest.Config.Digest), 37 | nil, 38 | ) 39 | if nil != err { 40 | return nil, err 41 | } 42 | 43 | request.Header.Add("Accept", v2Manifest.Config.MediaType) 44 | 45 | resp, err := client.Do(request) 46 | if nil != err { 47 | return nil, err 48 | } 49 | defer resp.Body.Close() 50 | 51 | if resp.StatusCode < 300 { 52 | err = json.NewDecoder(resp.Body).Decode(&wrapper) 53 | if nil == err { 54 | return &wrapper.Config, nil 55 | } 56 | } 57 | 58 | return nil, err 59 | } 60 | -------------------------------------------------------------------------------- /v2/docker/schema2/config_test.go: -------------------------------------------------------------------------------- 1 | package schema2 2 | 3 | import ( 4 | "testing" 5 | 6 | dockerTypes "github.com/docker/docker/api/types" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | vtesting "github.com/grafeas/voucher/v2/testing" 11 | ) 12 | 13 | func TestRequestConfig(t *testing.T) { 14 | ref := vtesting.NewTestReference(t) 15 | 16 | manifest := vtesting.NewTestManifest() 17 | 18 | client, server := vtesting.PrepareDockerTest(t, ref) 19 | defer server.Close() 20 | 21 | config, err := RequestConfig(client, ref, manifest) 22 | require.NoError(t, err, "failed to get config: %s", err) 23 | 24 | expectedConfig := &dockerTypes.ExecConfig{ 25 | User: "nobody", 26 | } 27 | 28 | assert.Equal(t, expectedConfig, config) 29 | } 30 | -------------------------------------------------------------------------------- /v2/docker/schema2/manifest_test.go: -------------------------------------------------------------------------------- 1 | package schema2 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | vtesting "github.com/grafeas/voucher/v2/testing" 10 | ) 11 | 12 | func TestToManifest(t *testing.T) { 13 | newManifest := vtesting.NewTestManifest() 14 | 15 | manifest, err := ToManifest(nil, nil, newManifest) 16 | require.NoError(t, err) 17 | assert.NotNil(t, manifest) 18 | } 19 | -------------------------------------------------------------------------------- /v2/docker/uri/project.go: -------------------------------------------------------------------------------- 1 | package uri 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/docker/distribution/reference" 8 | ) 9 | 10 | type ErrNoProjectInReference struct { 11 | ref reference.Reference 12 | } 13 | 14 | func (err *ErrNoProjectInReference) Error() string { 15 | return fmt.Sprintf("could not find project path in reference \"%s\"", err.ref) 16 | } 17 | 18 | // ReferenceToProjectName returns what should be the GCR project name for an 19 | // image reference. 20 | // 21 | // For example, if an image is in the project "my-cool-project" the image path 22 | // should start with `gcr.io/my-cool-project`. 23 | func ReferenceToProjectName(ref reference.Reference) (string, error) { 24 | values := strings.Split(ref.String(), "/") 25 | if len(values) > 2 { 26 | if values[0] == "gcr.io" { 27 | return values[1], nil 28 | } 29 | if strings.HasSuffix(values[0], ".pkg.dev") { 30 | return values[1], nil 31 | } 32 | } 33 | 34 | return "", &ErrNoProjectInReference{ 35 | ref: ref, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /v2/docker/uri/project_test.go: -------------------------------------------------------------------------------- 1 | package uri 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/distribution/reference" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestReferenceToProjectName(t *testing.T) { 12 | // map of reference to project 13 | cases := map[string]string{ 14 | "gcr.io/alpine/alpine@sha256:297524b7375fbf09b3784f0bbd9cb2505700dd05e03ce5f5e6d262bf2f5ac51c": "alpine", 15 | "alpine/alpine": "", 16 | "southamerica-east1-docker.pkg.dev/my-project/team1/webapp": "my-project", 17 | "australia-southeast1-docker.pkg.dev/my-project/team2/webapp": "my-project", 18 | } 19 | for img, expectedProject := range cases { 20 | t.Run(img, func(t *testing.T) { 21 | ref, err := reference.Parse(img) 22 | require.NoError(t, err) 23 | 24 | project, err := ReferenceToProjectName(ref) 25 | if expectedProject != "" { 26 | assert.NoError(t, err) 27 | assert.Equal(t, expectedProject, project) 28 | } else { 29 | assert.Error(t, err) 30 | } 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /v2/docker/uri/uri.go: -------------------------------------------------------------------------------- 1 | package uri 2 | 3 | import ( 4 | "bytes" 5 | "net/url" 6 | 7 | "github.com/docker/distribution/reference" 8 | digest "github.com/opencontainers/go-digest" 9 | ) 10 | 11 | // GetTokenURI gets the token URI for the passed repository. 12 | func GetTokenURI(ref reference.Named) string { 13 | hostname := reference.Domain(ref) 14 | repository := reference.Path(ref) 15 | 16 | query := url.Values{} 17 | query.Set("service", hostname) 18 | query.Set("scope", "repository:"+repository+":*") 19 | 20 | u := createURL(ref, "token") 21 | u.RawQuery = query.Encode() 22 | 23 | return u.String() 24 | } 25 | 26 | // GetBlobURI gets a blob URI based on the passed repository and 27 | // digest. 28 | func GetBlobURI(ref reference.Named, digest digest.Digest) string { 29 | u := createURL(ref, reference.Path(ref), "blobs", string(digest)) 30 | return u.String() 31 | } 32 | 33 | // GetManifestURI gets a manifest URI based on the passed repository and label (tag or digest). 34 | func GetManifestURI(ref reference.Named, label string) string { 35 | u := createURL(ref, reference.Path(ref), "manifests", label) 36 | return u.String() 37 | } 38 | 39 | // GetTagManifestURI gets a manifest URI based on the passed repository and 40 | // tag. 41 | func GetTagManifestURI(ref reference.NamedTagged) string { 42 | return GetManifestURI(ref, ref.Tag()) 43 | } 44 | 45 | // GetDigestManifestURI gets a manifest URI based on the passed repository and 46 | // digest. 47 | func GetDigestManifestURI(ref reference.Canonical) string { 48 | return GetManifestURI(ref, string(ref.Digest())) 49 | } 50 | 51 | func createURL(ref reference.Named, pathSegments ...string) url.URL { 52 | hostname := reference.Domain(ref) 53 | 54 | var path bytes.Buffer 55 | path.WriteString("/v2") 56 | 57 | for _, pathSegment := range pathSegments { 58 | path.WriteString("/") 59 | path.WriteString(pathSegment) 60 | } 61 | 62 | var u url.URL 63 | u.Scheme = "https" 64 | u.Host = hostname 65 | u.Path = path.String() 66 | 67 | return u 68 | } 69 | -------------------------------------------------------------------------------- /v2/docker/uri/uri_test.go: -------------------------------------------------------------------------------- 1 | package uri 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/distribution/reference" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | const ( 12 | testHostname = "gcr.io" 13 | testProject = "test/project" 14 | testDigest = "sha256:cb749360c5198a55859a7f335de3cf4e2f64b60886a2098684a2f9c7ffca81f2" 15 | testBlobURL = "https://" + testHostname + "/v2/" + testProject + "/blobs/" + testDigest 16 | testManifestURL = "https://" + testHostname + "/v2/" + testProject + "/manifests/" + testDigest 17 | testTokenURL = "https://" + testHostname + "/v2/token?scope=repository%3Atest%2Fproject%3A%2A&service=gcr.io" 18 | ) 19 | 20 | func TestGetBaseURI(t *testing.T) { 21 | named, err := reference.ParseNamed(testHostname + "/" + testProject + "@" + testDigest) 22 | require.NoError(t, err, "failed to parse uri: %s", err) 23 | 24 | assert.Equal(t, testTokenURL, GetTokenURI(named)) 25 | 26 | canonicalRef, ok := named.(reference.Canonical) 27 | require.True(t, ok) 28 | 29 | assert.Equal(t, string(canonicalRef.Digest()), testDigest) 30 | hostname, path := reference.SplitHostname(canonicalRef) 31 | assert.Equal(t, hostname, "gcr.io") 32 | assert.Equal(t, path, testProject) 33 | assert.Equal(t, testBlobURL, GetBlobURI(canonicalRef, canonicalRef.Digest())) 34 | assert.Equal(t, testManifestURL, GetDigestManifestURI(canonicalRef)) 35 | } 36 | -------------------------------------------------------------------------------- /v2/grafeas/errors.go: -------------------------------------------------------------------------------- 1 | package grafeas 2 | 3 | import ( 4 | "errors" 5 | 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/status" 8 | ) 9 | 10 | // Grafeas client errors 11 | var ( 12 | errNoOccurrences = errors.New("no occurrences returned for image") 13 | errDiscoveriesUnfinished = errors.New("discoveries have not finished processing") 14 | ) 15 | 16 | // isAttestationExistsErr returns true if the passed Error is an "AlreadyExists" gRPC error. 17 | func isAttestationExistsErr(err error) bool { 18 | if nil == err { 19 | return false 20 | } 21 | 22 | return (codes.AlreadyExists == status.Code(err)) 23 | } 24 | -------------------------------------------------------------------------------- /v2/grafeas/grafeas_api_error.go: -------------------------------------------------------------------------------- 1 | package grafeas 2 | 3 | import "fmt" 4 | 5 | // APIError to store grafeas API errors 6 | type APIError struct { 7 | statusCode int 8 | url string 9 | method string 10 | requestData string 11 | } 12 | 13 | // Error returns the grafeas API error as a string. 14 | func (err *APIError) Error() string { 15 | if err.requestData != "" { 16 | return fmt.Sprintf("error getting REST data with status code %d for url %s and method %s with data: %v", err.statusCode, err.url, err.method, err.requestData) 17 | } 18 | return fmt.Sprintf("error getting REST data with status code %d for url %s and method %s", err.statusCode, err.url, err.method) 19 | } 20 | 21 | // NewAPIError creates a new APIError 22 | func NewAPIError(statusCode int, url, method string, data []byte) error { 23 | return &APIError{ 24 | statusCode: statusCode, 25 | url: url, 26 | method: method, 27 | requestData: string(data), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /v2/grafeas/objects/discovery.go: -------------------------------------------------------------------------------- 1 | package objects 2 | 3 | // DiscoveredAnalysisStatus based on 4 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_discovered_analysis_status.go 5 | type DiscoveredAnalysisStatus string 6 | 7 | // consts 8 | const ( 9 | DiscoveredAnalysisStatusUnspecified DiscoveredAnalysisStatus = "ANALYSIS_STATUS_UNSPECIFIED" 10 | DiscoveredAnalysisStatusPending DiscoveredAnalysisStatus = "PENDING" 11 | DiscoveredAnalysisStatusScanning DiscoveredAnalysisStatus = "SCANNING" 12 | DiscoveredAnalysisStatusFinishedSuccess DiscoveredAnalysisStatus = "FINISHED_SUCCESS" 13 | DiscoveredAnalysisStatusFinishedFailed DiscoveredAnalysisStatus = "FINISHED_FAILED" 14 | DiscoveredAnalysisStatusFinishedUnsupported DiscoveredAnalysisStatus = "FINISHED_UNSUPPORTED" 15 | ) 16 | 17 | //discovery for occurrence 18 | 19 | // DiscoveryDetails based on 20 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1discovery_details.go 21 | type DiscoveryDetails struct { 22 | Discovered *DiscoveryDiscovered `json:"discovered,omitempty"` //required 23 | } 24 | 25 | // DiscoveryDiscovered based on 26 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_discovery_discovered.go 27 | type DiscoveryDiscovered struct { 28 | AnalysisStatus *DiscoveredAnalysisStatus `json:"analysisStatus,omitempty"` 29 | } 30 | 31 | //discovery for note 32 | 33 | // Discovery based on 34 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_discovery_discovery.go 35 | type Discovery struct { 36 | AnalysisKind *NoteKind `json:"analysisKind,omitempty"` //required 37 | } 38 | -------------------------------------------------------------------------------- /v2/grafeas/objects/exchange.go: -------------------------------------------------------------------------------- 1 | package objects 2 | 3 | import ( 4 | "github.com/antihax/optional" 5 | ) 6 | 7 | // ListOpts based on 8 | // ListNotesOpts https://github.com/grafeas/client-go/blob/39fa98b49d38de3942716c0f58f3505012415470/0.1.0/api_grafeas_v1_beta1.go#L1051 9 | // ListNoteOccurrencesOpts https://github.com/grafeas/client-go/blob/39fa98b49d38de3942716c0f58f3505012415470/0.1.0/api_grafeas_v1_beta1.go#L943 10 | type ListOpts struct { 11 | Filter optional.String //not implemented for grafeas os 12 | PageSize optional.Int32 13 | PageToken optional.String 14 | } 15 | 16 | // ListNotesResponse based on 17 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1_list_notes_response.go 18 | type ListNotesResponse struct { 19 | Notes []Note `json:"notes,omitempty"` 20 | NextPageToken string `json:"nextPageToken,omitempty"` 21 | } 22 | 23 | // ListOccurrencesResponse based on 24 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1_list_note_occurrences_response.go 25 | type ListOccurrencesResponse struct { 26 | Occurrences []Occurrence `json:"occurrences,omitempty"` 27 | NextPageToken string `json:"nextPageToken,omitempty"` 28 | } 29 | -------------------------------------------------------------------------------- /v2/grafeas/objects/note.go: -------------------------------------------------------------------------------- 1 | package objects 2 | 3 | import "time" 4 | 5 | // NoteKind based on 6 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1_note_kind.go 7 | type NoteKind string 8 | 9 | // consts 10 | const ( 11 | NoteKindUspecified NoteKind = "NOTE_KIND_UNSPECIFIED" 12 | NoteKindVulnerability NoteKind = "VULNERABILITY" 13 | NoteKindBuild NoteKind = "BUILD" 14 | NoteKindImage NoteKind = "IMAGE" 15 | NoteKindPackage NoteKind = "PACKAGE" 16 | NoteKindDeployment NoteKind = "DEPLOYMENT" 17 | NoteKindDiscovery NoteKind = "DISCOVERY" 18 | NoteKindAttestation NoteKind = "ATTESTATION" 19 | ) 20 | 21 | // Note based on 22 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1_note.go 23 | type Note struct { 24 | Name string `json:"name,omitempty"` //output only 25 | ShortDescription string `json:"shortDescription,omitempty"` 26 | LongDescription string `json:"longDescription,omitempty"` 27 | Kind *NoteKind `json:"kind,omitempty"` //output only 28 | ExpirationTime time.Time `json:"expirationTime,omitempty"` 29 | CreateTime time.Time `json:"createTime,omitempty"` //output only 30 | UpdateTime time.Time `json:"updateTime,omitempty"` //output only 31 | RelatedNoteNames []string `json:"relatedNoteNames,omitempty"` 32 | Vulnerability *Vulnerability `json:"vulnerability,omitempty"` 33 | Build *Build `json:"build,omitempty"` 34 | Package *Package `json:"package,omitempty"` 35 | Discovery *Discovery `json:"discovery,omitempty"` 36 | } 37 | -------------------------------------------------------------------------------- /v2/grafeas/objects/occurrence.go: -------------------------------------------------------------------------------- 1 | package objects 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/docker/distribution/reference" 7 | ) 8 | 9 | // Occurrence based on 10 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1_occurrence.go 11 | type Occurrence struct { 12 | //output only, form: `projects/[PROJECT_ID]/occurrences/[OCCURRENCE_ID] 13 | Name string `json:"name,omitempty"` 14 | Resource *Resource `json:"resource,omitempty"` //required 15 | NoteName string `json:"noteName,omitempty"` //required, form: `projects/[PROVIDER_ID]/notes/[NOTE_ID]` 16 | Kind *NoteKind `json:"kind,omitempty"` //output only 17 | Remediation string `json:"remediation,omitempty"` 18 | CreateTime time.Time `json:"createTime,omitempty"` //output only 19 | UpdateTime time.Time `json:"updateTime,omitempty"` //output only 20 | Vulnerability *VulnerabilityDetails `json:"vulnerability,omitempty"` 21 | Build *BuildDetails `json:"build,omitempty"` 22 | Discovered *DiscoveryDetails `json:"discovered,omitempty"` 23 | Attestation *AttestationDetails `json:"attestation,omitempty"` 24 | } 25 | 26 | // Resource based on 27 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_v1beta1_resource.go 28 | type Resource struct { 29 | URI string `json:"uri,omitempty"` //required 30 | } 31 | 32 | // NewOccurrence creates new occurrence 33 | func NewOccurrence(reference reference.Canonical, parentNoteID string, attestation *AttestationDetails, binauthProjectPath string) Occurrence { 34 | noteName := binauthProjectPath + "/notes/" + parentNoteID 35 | 36 | resource := Resource{ 37 | URI: "https://" + reference.Name() + "@" + reference.Digest().String(), 38 | } 39 | 40 | noteKind := NoteKindAttestation 41 | 42 | occurrence := Occurrence{Resource: &resource, NoteName: noteName, Kind: ¬eKind, Attestation: attestation} 43 | 44 | return occurrence 45 | } 46 | -------------------------------------------------------------------------------- /v2/grafeas/objects/package.go: -------------------------------------------------------------------------------- 1 | package objects 2 | 3 | // VersionKind based on 4 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_version_version_kind.go 5 | type VersionKind string 6 | 7 | // consts 8 | const ( 9 | VersionKindUnspecified VersionKind = "VERSION_KIND_UNSPECIFIED" 10 | VersionKindNormal VersionKind = "NORMAL" 11 | VersionKindMinimum VersionKind = "MINIMUM" 12 | VVersionKindMaximum VersionKind = "MAXIMUM" 13 | ) 14 | 15 | // Package based on 16 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_package_package.go 17 | type Package struct { 18 | Name string `json:"name,omitempty"` //required 19 | } 20 | 21 | // PackageVersion based on 22 | // https://github.com/grafeas/client-go/blob/master/0.1.0/model_package_version.go 23 | type PackageVersion struct { 24 | Epoch int32 `json:"epoch,omitempty"` 25 | Name string `json:"name,omitempty"` //required only when version kind is NORMAL 26 | Revision string `json:"revision,omitempty"` 27 | Kind *VersionKind `json:"kind,omitempty"` //required 28 | } 29 | -------------------------------------------------------------------------------- /v2/grafeas/poll.go: -------------------------------------------------------------------------------- 1 | package grafeas 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/docker/distribution/reference" 8 | voucher "github.com/grafeas/voucher/v2" 9 | "github.com/grafeas/voucher/v2/docker/uri" 10 | "github.com/grafeas/voucher/v2/grafeas/objects" 11 | ) 12 | 13 | var ( 14 | attempts = 5 15 | sleep = time.Second * 10 16 | ) 17 | 18 | func setPollOptions(attemptsOption int, sleepOption time.Duration) { 19 | attempts = attemptsOption 20 | sleep = sleepOption 21 | } 22 | 23 | func defaultPollOptions() { 24 | attempts = 5 25 | sleep = time.Second * 10 26 | } 27 | 28 | // isDone returns true if the passed discovery has finished, false otherwise. 29 | func isDone(occurrence *objects.Occurrence) bool { 30 | occDiscovery := occurrence.Discovered 31 | if nil != occDiscovery { 32 | discovered := occDiscovery.Discovered 33 | if nil != discovered { 34 | if objects.DiscoveredAnalysisStatusFinishedSuccess == *discovered.AnalysisStatus { 35 | return true 36 | } 37 | } 38 | } 39 | 40 | return false 41 | } 42 | 43 | // pollForDiscoveries pauses execution until grafeas has pushed 44 | // the Vulnerability information to the server. 45 | func pollForDiscoveries(ctx context.Context, c *Client, ref reference.Reference) error { 46 | for i := 0; i < attempts; i++ { 47 | discoveries, err := getVulnerabilityDiscoveries(ctx, c, ref) 48 | if err != nil && !voucher.IsNoMetadataError(err) { 49 | return err 50 | } 51 | if len(discoveries) > 0 { 52 | for _, discoveryItem := range discoveries { 53 | if isDone(&discoveryItem) { 54 | return nil 55 | } 56 | } 57 | } 58 | time.Sleep(sleep) 59 | } 60 | return errDiscoveriesUnfinished 61 | } 62 | 63 | func getVulnerabilityDiscoveries(ctx context.Context, g *Client, ref reference.Reference) (items []objects.Occurrence, err error) { 64 | project, err := uri.ReferenceToProjectName(ref) 65 | if nil != err { 66 | return nil, err 67 | } 68 | 69 | occurrences, err := g.getAllOccurrences(ctx, project) 70 | 71 | for _, occ := range occurrences { 72 | if *occ.Kind == objects.NoteKindDiscovery { 73 | items = append(items, occ) 74 | } 75 | } 76 | 77 | if 0 == len(items) && nil == err { 78 | err = &voucher.NoMetadataError{ 79 | Type: DiscoveryType, 80 | Err: errNoOccurrences, 81 | } 82 | } 83 | return 84 | } 85 | -------------------------------------------------------------------------------- /v2/grafeas/types.go: -------------------------------------------------------------------------------- 1 | package grafeas 2 | 3 | import voucher "github.com/grafeas/voucher/v2" 4 | 5 | // DiscoveryType is a Grafeas specific type which refers to MetadataItems containing metadata discovery status. 6 | const DiscoveryType voucher.MetadataType = "discovery" 7 | 8 | // PackageType is a Grafeas specific type which refers to MetadataItems containing package information. 9 | const PackageType voucher.MetadataType = "package" 10 | 11 | // ImageType is a Grafeas specific type which refers to MetadataItems containing Image information. 12 | const ImageType voucher.MetadataType = "image" 13 | 14 | // DeploymentType is a Grafeas specific type which refers to MetadataItems containing deployment data. 15 | const DeploymentType voucher.MetadataType = "deployment" 16 | -------------------------------------------------------------------------------- /v2/imagedata.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/docker/distribution/reference" 7 | ) 8 | 9 | // ImageData is a Canonical Reference to the Image (includes digest and URL). 10 | type ImageData = reference.Canonical 11 | 12 | // NewImageData creates a new ImageData item with the passed URL as 13 | // a reference to the target image. 14 | func NewImageData(url string) (ImageData, error) { 15 | var imageData ImageData 16 | rawRef, err := reference.Parse(url) 17 | if nil != err { 18 | return imageData, fmt.Errorf("can't use URL in ImageData: %s", err) 19 | } 20 | 21 | canonicalRef, isCanonical := rawRef.(reference.Canonical) 22 | if !isCanonical { 23 | return imageData, fmt.Errorf("reference %s has no digest", rawRef.String()) 24 | } 25 | 26 | return canonicalRef, nil 27 | } 28 | -------------------------------------------------------------------------------- /v2/imagedata_test.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewImageData(t *testing.T) { 10 | assert := assert.New(t) 11 | 12 | _, err := NewImageData("!!abc") 13 | if assert.Error(err) { 14 | assert.Equal("can't use URL in ImageData: invalid reference format", err.Error()) 15 | } 16 | 17 | _, err = NewImageData("gcr.io/path/to/image") 18 | if assert.Error(err) { 19 | assert.Equal("reference gcr.io/path/to/image has no digest", err.Error()) 20 | } 21 | 22 | _, err = NewImageData("gcr.io/path/to/image@sha256:97db2bc359ccc94d3b2d6f5daa4173e9e91c513b0dcd961408adbb95ec5e5ce5") 23 | assert.NoError(err) 24 | } 25 | -------------------------------------------------------------------------------- /v2/interface.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/distribution/reference" 7 | ) 8 | 9 | // Interface represents an interface to the Voucher API. Typically Voucher API 10 | // clients would implement it. 11 | type Interface interface { 12 | Check(ctx context.Context, check string, image reference.Canonical) (Response, error) 13 | Verify(ctx context.Context, check string, image reference.Canonical) (Response, error) 14 | } 15 | -------------------------------------------------------------------------------- /v2/metadatacheck.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // MetadataCheck represents a Voucher check that interacts 4 | // directly with a metadata server. 5 | type MetadataCheck interface { 6 | Check 7 | SetMetadataClient(MetadataClient) 8 | } 9 | -------------------------------------------------------------------------------- /v2/metadataclient.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/docker/distribution/reference" 8 | "github.com/grafeas/voucher/v2/repository" 9 | ) 10 | 11 | // MetadataClient is an interface that represents something that communicates 12 | // with the Metadata server. 13 | type MetadataClient interface { 14 | CanAttest() bool 15 | NewPayloadBody(ImageData) (string, error) 16 | GetVulnerabilities(context.Context, ImageData) ([]Vulnerability, error) 17 | GetBuildDetail(context.Context, reference.Canonical) (repository.BuildDetail, error) 18 | AddAttestationToImage(context.Context, ImageData, Attestation) (SignedAttestation, error) 19 | GetAttestations(context.Context, ImageData) ([]SignedAttestation, error) 20 | Close() 21 | } 22 | 23 | // NoMetadataError is an error that is returned when we request metadata that 24 | // should exist but doesn't. It's a general error that will wrap more specific 25 | // errors if desired. 26 | type NoMetadataError struct { 27 | Type MetadataType 28 | Err error 29 | } 30 | 31 | // Error returns the error value of this NoMetadataError as a string. 32 | func (err *NoMetadataError) Error() string { 33 | return fmt.Sprintf("no metadata of type %s returned: %s", err.Type, err.Err) 34 | } 35 | 36 | // IsNoMetadataError returns true if the passed error is a NoMetadataError. 37 | func IsNoMetadataError(err error) bool { 38 | _, ok := err.(*NoMetadataError) 39 | return ok 40 | } 41 | -------------------------------------------------------------------------------- /v2/metadatatype.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // MetadataType is a type which represents a MetadataClient's MetadataItem type. 4 | type MetadataType string 5 | 6 | const ( 7 | // VulnerabilityType is specific to MetadataItem containing vulnerabilities. 8 | VulnerabilityType MetadataType = "vulnerability" 9 | // BuildDetailsType refers to MetadataItems containing image build details. 10 | BuildDetailsType MetadataType = "build details" 11 | // AttestationType refers to MetadataItems containing Binary Authorization Attestations. 12 | AttestationType MetadataType = "attestation" 13 | ) 14 | -------------------------------------------------------------------------------- /v2/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Client interface { 8 | CheckRunStart(string) 9 | CheckRunLatency(string, time.Duration) 10 | CheckAttestationLatency(string, time.Duration) 11 | CheckRunFailure(string) 12 | CheckRunError(string, error) 13 | CheckRunSuccess(string) 14 | CheckAttestationStart(string) 15 | CheckAttestationError(string, error) 16 | CheckAttestationSuccess(string) 17 | PubSubMessageReceived() 18 | PubSubTotalLatency(time.Duration) 19 | } 20 | -------------------------------------------------------------------------------- /v2/metrics/noop_client.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type NoopClient struct{} 8 | 9 | func (*NoopClient) CheckRunStart(string) {} 10 | func (*NoopClient) CheckRunLatency(string, time.Duration) {} 11 | func (*NoopClient) CheckAttestationLatency(string, time.Duration) {} 12 | func (*NoopClient) CheckRunFailure(string) {} 13 | func (*NoopClient) CheckRunError(string, error) {} 14 | func (*NoopClient) CheckRunSuccess(string) {} 15 | func (*NoopClient) CheckAttestationStart(string) {} 16 | func (*NoopClient) CheckAttestationError(string, error) {} 17 | func (*NoopClient) CheckAttestationSuccess(string) {} 18 | func (*NoopClient) PubSubMessageReceived() {} 19 | func (*NoopClient) PubSubTotalLatency(time.Duration) {} 20 | -------------------------------------------------------------------------------- /v2/metrics/otel_test.go: -------------------------------------------------------------------------------- 1 | package metrics_test 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "testing" 7 | "time" 8 | 9 | "github.com/grafeas/voucher/v2/metrics" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "go.opentelemetry.io/otel/sdk/metric" 14 | "go.opentelemetry.io/otel/sdk/metric/metricdata" 15 | ) 16 | 17 | func TestOpenTelemetryClient(t *testing.T) { 18 | reader := metric.NewManualReader() 19 | 20 | // Exercise the client to produce some metrics: 21 | client, err := metrics.NewOpenTelemetryClient(metric.NewMeterProvider(metric.WithReader(reader)), nil) 22 | require.NoError(t, err) 23 | for i := 0; i < 10; i++ { 24 | client.CheckRunStart("diy") 25 | client.CheckAttestationLatency("diy", time.Duration(rand.Intn(500)+500)*time.Millisecond) 26 | } 27 | 28 | metrics, err := reader.Collect(context.Background()) 29 | require.NoError(t, err) 30 | require.Len(t, metrics.ScopeMetrics, 1) 31 | require.Len(t, metrics.ScopeMetrics[0].Metrics, 11, "total metric count") 32 | 33 | // Verify the metrics we triggered are present: 34 | names := make(map[string]struct{}, len(metrics.ScopeMetrics[0].Metrics)) 35 | for _, m := range metrics.ScopeMetrics[0].Metrics { 36 | names[m.Name] = struct{}{} 37 | switch m.Name { 38 | case "voucher_check_run_start_total": 39 | agg := m.Data.(metricdata.Sum[int64]) 40 | assert.Len(t, agg.DataPoints, 1) 41 | assert.Equal(t, int64(10), agg.DataPoints[0].Value) 42 | case "voucher_check_attestation_latency_milliseconds": 43 | agg := m.Data.(metricdata.Histogram) 44 | assert.Len(t, agg.DataPoints, 1) 45 | assert.Equal(t, uint64(10), agg.DataPoints[0].Count) 46 | assert.GreaterOrEqual(t, *agg.DataPoints[0].Min, float64(500)) 47 | assert.LessOrEqual(t, *agg.DataPoints[0].Max, float64(1000)) 48 | } 49 | } 50 | assert.Contains(t, names, "voucher_check_run_start_total") 51 | assert.Contains(t, names, "voucher_check_attestation_latency_milliseconds") 52 | } 53 | -------------------------------------------------------------------------------- /v2/mock_check.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | type MockCheck struct { 10 | mock.Mock 11 | } 12 | 13 | func (m *MockCheck) Check(ctx context.Context, i ImageData) (bool, error) { 14 | args := m.Called(ctx, i) 15 | return args.Bool(0), args.Error(1) 16 | } 17 | -------------------------------------------------------------------------------- /v2/mock_metadataclient.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/distribution/reference" 7 | "github.com/stretchr/testify/mock" 8 | 9 | "github.com/grafeas/voucher/v2/repository" 10 | ) 11 | 12 | type MockMetadataClient struct { 13 | mock.Mock 14 | } 15 | 16 | func (m *MockMetadataClient) CanAttest() bool { 17 | args := m.Called() 18 | return args.Bool(0) 19 | } 20 | 21 | func (m *MockMetadataClient) NewPayloadBody(imageData ImageData) (string, error) { 22 | args := m.Called(imageData) 23 | return args.String(0), args.Error(1) 24 | } 25 | 26 | func (m *MockMetadataClient) GetVulnerabilities(ctx context.Context, imageData ImageData) ([]Vulnerability, error) { 27 | args := m.Called(ctx, imageData) 28 | return args.Get(0).([]Vulnerability), args.Error(1) 29 | } 30 | 31 | func (m *MockMetadataClient) GetBuildDetail(ctx context.Context, ref reference.Canonical) (repository.BuildDetail, error) { 32 | args := m.Called(ctx, ref) 33 | return args.Get(0).(repository.BuildDetail), args.Error(1) 34 | } 35 | 36 | func (m *MockMetadataClient) AddAttestationToImage(ctx context.Context, imageData ImageData, attestation Attestation) (SignedAttestation, error) { 37 | args := m.Called(ctx, imageData, attestation) 38 | return args.Get(0).(SignedAttestation), args.Error(1) 39 | } 40 | 41 | func (m *MockMetadataClient) GetAttestations(ctx context.Context, imageData ImageData) ([]SignedAttestation, error) { 42 | args := m.Called(ctx, imageData) 43 | return args.Get(0).([]SignedAttestation), args.Error(1) 44 | } 45 | 46 | func (m *MockMetadataClient) Close() { 47 | m.Called() 48 | } 49 | -------------------------------------------------------------------------------- /v2/provenancecheck.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // ProvenanceCheck represents a Voucher check that sets 4 | // trusted projects and build creators 5 | type ProvenanceCheck interface { 6 | Check 7 | SetTrustedBuildCreators([]string) 8 | SetTrustedProjects([]string) 9 | } 10 | -------------------------------------------------------------------------------- /v2/register.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // CheckFactory is a type of function that creates a new Check. 8 | type CheckFactory func() Check 9 | 10 | // CheckFactories is a map of registered CheckFactories. 11 | type CheckFactories map[string]CheckFactory 12 | 13 | // Register adds a new CheckFactory to this CheckFactories. 14 | func (cf CheckFactories) Register(name string, creator CheckFactory) { 15 | if nil == cf[name] { 16 | cf[name] = creator 17 | } 18 | } 19 | 20 | // Get returns the CheckFactory with the passed name. 21 | func (cf CheckFactories) Get(name string) CheckFactory { 22 | return cf[name] 23 | } 24 | 25 | // GetNewChecks gets new copies of the Checks from each of their registered 26 | // CheckFactory. 27 | func (cf CheckFactories) GetNewChecks(names ...string) (map[string]Check, error) { 28 | checks := make(map[string]Check, len(cf)) 29 | for _, name := range names { 30 | creator := cf.Get(name) 31 | if nil == creator { 32 | return checks, fmt.Errorf("requested check \"%s\" does not exist", name) 33 | } 34 | checks[name] = creator() 35 | } 36 | return checks, nil 37 | } 38 | 39 | // DefaultCheckFactories is the default CheckFactory collection. 40 | var DefaultCheckFactories = make(CheckFactories) 41 | 42 | // RegisterCheckFactory adds a CheckFactory to the DefaultCheckFactories 43 | // that can be run. Once a Check is added, it can be referenced by the name 44 | // that was passed in when this function was called. 45 | func RegisterCheckFactory(name string, creator CheckFactory) { 46 | DefaultCheckFactories.Register(name, creator) 47 | } 48 | 49 | // GetCheckFactories gets new copies of the Checks from their registered 50 | // CheckFactories. 51 | func GetCheckFactories(names ...string) (map[string]Check, error) { 52 | return DefaultCheckFactories.GetNewChecks(names...) 53 | } 54 | 55 | // IsCheckFactoryRegistered returns true if the passed CheckFactory was 56 | // registered. 57 | func IsCheckFactoryRegistered(name string) bool { 58 | return (nil != DefaultCheckFactories.Get(name)) 59 | } 60 | -------------------------------------------------------------------------------- /v2/repository/README.md: -------------------------------------------------------------------------------- 1 | # Source Code Repositories 2 | 3 | Voucher is capable of retrieving commit metadata from different version control sources (i.e., GitHub, GitLab, GitTea, etc.). 4 | 5 | At the moment, Voucher supports GitHub metadata as a source. 6 | -------------------------------------------------------------------------------- /v2/repository/auth.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | // KeyRing contains all the authentication keys 4 | // needed to communicate an org's repository source 5 | type KeyRing map[string]Auth 6 | 7 | // Auth holds the necessary information to connect to a repository source 8 | type Auth struct { 9 | Token string `json:"token"` 10 | Username string `json:"username"` 11 | Password string `json:"password"` 12 | AppID string `json:"_app_id"` 13 | InstallationID string `json:"_installation_id"` 14 | PrivateKey string `json:"private_key"` 15 | } 16 | 17 | // Type determines the authentication method being used to connect to a source 18 | func (a *Auth) Type() string { 19 | if a.Token != "" { 20 | return TokenAuthType 21 | } 22 | 23 | if a.Username != "" && a.Password != "" { 24 | return UserPasswordAuthType 25 | } 26 | 27 | if a.AppID != "" && a.InstallationID != "" && a.PrivateKey != "" { 28 | return GithubInstallType 29 | } 30 | 31 | return "" 32 | } 33 | -------------------------------------------------------------------------------- /v2/repository/build_artifact.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | // BuildArtifact is a type that describes the artifact info 4 | // related to a build 5 | type BuildArtifact struct { 6 | ID string `json:"repository"` 7 | Checksum string `json:"commit"` 8 | } 9 | 10 | func (b *BuildArtifact) String() string { 11 | str := "" 12 | if b.ID != "" { 13 | str += "ID: " + b.ID + "\n" 14 | } 15 | if b.Checksum != "" { 16 | str += "Checksum: " + b.Checksum + "\n" 17 | } 18 | return str 19 | } 20 | -------------------------------------------------------------------------------- /v2/repository/build_detail.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // BuildDetail is a type that describes the details/metadata info 8 | // related to a build 9 | type BuildDetail struct { 10 | RepositoryURL string `json:"repository"` 11 | Commit string `json:"commit"` 12 | BuildCreator string `json:"build_creator"` 13 | BuildURL string `json:"build_url"` 14 | ProjectID string `json:"project_id"` 15 | Artifacts []BuildArtifact `json:"artifacts"` 16 | } 17 | 18 | func (b *BuildDetail) String() string { 19 | str := "" 20 | if b.RepositoryURL != "" { 21 | str += "RepositoryURL: " + b.RepositoryURL + "\n" 22 | } 23 | if b.Commit != "" { 24 | str += "Commit: " + b.Commit + "\n" 25 | } 26 | if b.BuildCreator != "" { 27 | str += "BuildCreator: " + b.BuildCreator + "\n" 28 | } 29 | if b.BuildURL != "" { 30 | str += "BuildURL: " + b.BuildURL + "\n" 31 | } 32 | if b.ProjectID != "" { 33 | str += "ProjectID: " + b.ProjectID + "\n" 34 | } 35 | strArtifacts := "" 36 | for _, val := range b.Artifacts { 37 | if val.String() != "" { 38 | strArtifacts = strings.Join([]string{strArtifacts, val.String()}, ", ") 39 | } 40 | } 41 | if strArtifacts != "" { 42 | str += "Artifacts: " + strArtifacts + "\n" 43 | } 44 | return str 45 | } 46 | -------------------------------------------------------------------------------- /v2/repository/client.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "context" 4 | 5 | // Client is a client for a version control source 6 | type Client interface { 7 | GetCommit(ctx context.Context, details BuildDetail) (Commit, error) 8 | GetOrganization(ctx context.Context, details BuildDetail) (Organization, error) 9 | GetBranch(ctx context.Context, details BuildDetail, name string) (Branch, error) 10 | GetDefaultBranch(ctx context.Context, details BuildDetail) (Branch, error) 11 | } 12 | -------------------------------------------------------------------------------- /v2/repository/config.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | // Config contains the necessary parameters to authenticate/communicate with a source repository 4 | type Config struct { 5 | Auth KeyRing 6 | Organization string `json:"org-name"` 7 | OrganizationURL string `json:"org-url"` 8 | } 9 | -------------------------------------------------------------------------------- /v2/repository/consts.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | // TokenAuthType is the type representing a token-based authentication method 4 | const TokenAuthType = "token" 5 | 6 | // UserPasswordAuthType is the type representing a token-based authentication method 7 | const UserPasswordAuthType = "userpassword" 8 | 9 | // GithubInstallType is the type representing a Github install 10 | const GithubInstallType = "githubinstall" 11 | -------------------------------------------------------------------------------- /v2/repository/errors.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "fmt" 4 | 5 | // TypeMismatchError represents a type mismatch between objects 6 | type typeMismatchError struct { 7 | expectedType string 8 | actualType string 9 | } 10 | 11 | func (t *typeMismatchError) Error() string { 12 | return fmt.Sprintf("type mismatch found. Expected: %s, Actual: %s", t.expectedType, t.actualType) 13 | } 14 | 15 | // NewTypeMismatchError creates a new TypeMismatchError 16 | func NewTypeMismatchError(expected string, actual string) error { 17 | return &typeMismatchError{ 18 | expectedType: expected, 19 | actualType: actual, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /v2/repository/github/branch_protections_query.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/shurcooL/githubv4" 4 | 5 | // branchProtectionsQuery is the GraphQL query for retrieving information pertaining to branch protections in a repository 6 | type branchProtectionsQuery struct { 7 | Resource struct { 8 | Typename string `graphql:"__typename"` 9 | Repository struct { 10 | BranchProtectionRules struct { 11 | PageInfo struct { 12 | EndCursor githubv4.String 13 | HasNextPage bool 14 | } 15 | Nodes []branchProtectionRule 16 | } `graphql:"branchProtectionRules(first:100, after: $branchProtectionRulesCursor)"` 17 | } `graphql:"... on Repository"` 18 | } `graphql:"resource(url: $url)"` 19 | } 20 | 21 | type branchProtectionRule struct { 22 | RequiresApprovingReviews bool 23 | RequiredApprovingReviewCount int 24 | MatchingRefs struct { 25 | PageInfo struct { 26 | EndCursor githubv4.String 27 | HasNextPage bool 28 | } 29 | Nodes []matchingRef 30 | } `graphql:"matchingRefs(first: 100, after: $matchingRefsCursor)"` 31 | } 32 | 33 | type matchingRef struct { 34 | Name string 35 | } 36 | -------------------------------------------------------------------------------- /v2/repository/github/branch_query.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/shurcooL/githubv4" 4 | 5 | // branchQuery is the GraphQL query for retrieving information pertaining to a branch in a repository 6 | type branchQuery struct { 7 | Resource struct { 8 | Typename string `graphql:"__typename"` 9 | Repository struct { 10 | Ref struct { 11 | Name string 12 | Target struct { 13 | Commit struct { 14 | Typename string `graphql:"__typename"` 15 | History struct { 16 | PageInfo struct { 17 | EndCursor githubv4.String 18 | HasNextPage bool 19 | } 20 | Typename string `graphql:"__typename"` 21 | Nodes []commit // Nodes contains all of the commits in the branch 22 | } `graphql:"history(first: 100, after: $branchCommitCursor)"` 23 | } `graphql:"... on Commit"` 24 | } 25 | } `graphql:"ref(qualifiedName: $branch_name)"` 26 | } `graphql:"... on Repository"` 27 | } `graphql:"resource(url: $url)"` 28 | } 29 | -------------------------------------------------------------------------------- /v2/repository/github/branch_result.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafeas/voucher/v2/repository" 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | // newBranchResult calls the branchQuery and populates the results with the respective variables 11 | func newBranchResult(ctx context.Context, ghc ghGraphQLClient, repoURL string, branchName string) (repository.Branch, error) { 12 | formattedURI, err := createNewGitHubV4URI(repoURL) 13 | if err != nil { 14 | return repository.Branch{}, err 15 | } 16 | queryResult := new(branchQuery) 17 | allBranchCommits := make([]commit, 0) 18 | branchInfoVariables := map[string]interface{}{ 19 | "url": githubv4.URI(*formattedURI), 20 | "branchCommitCursor": (*githubv4.String)(nil), 21 | "branch_name": (githubv4.String)(branchName), 22 | } 23 | 24 | err = paginationQuery(ctx, ghc, queryResult, branchInfoVariables, queryPageLimit, func(v interface{}) (bool, error) { 25 | dbq, ok := v.(*branchQuery) 26 | if !ok { 27 | return false, newTypeMismatchError("branchQuery", dbq) 28 | } 29 | resourceType := v.(*branchQuery).Resource.Typename 30 | if resourceType != repositoryType { 31 | return false, repository.NewTypeMismatchError(repositoryType, resourceType) 32 | } 33 | repo := dbq.Resource.Repository 34 | 35 | allBranchCommits = append(allBranchCommits, repo.Ref.Target.Commit.History.Nodes...) 36 | hasMoreResults := repo.Ref.Target.Commit.History.PageInfo.HasNextPage 37 | branchInfoVariables["branchCommitCursor"] = githubv4.NewString(repo.Ref.Target.Commit.History.PageInfo.EndCursor) 38 | return hasMoreResults, nil 39 | }) 40 | if err != nil { 41 | return repository.Branch{}, err 42 | } 43 | 44 | branchNameResult := queryResult.Resource.Repository.Ref.Name 45 | commits := make([]repository.CommitRef, 0) 46 | for _, commit := range allBranchCommits { 47 | repoCommit := repository.NewCommitRef(commit.URL) 48 | commits = append(commits, repoCommit) 49 | } 50 | return repository.NewBranch(branchNameResult, commits), nil 51 | } 52 | -------------------------------------------------------------------------------- /v2/repository/github/client_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/grafeas/voucher/v2/repository" 11 | ) 12 | 13 | func TestNewClient(t *testing.T) { 14 | var auth *repository.Auth 15 | 16 | t.Run("Test with valid auth method", func(t *testing.T) { 17 | auth = &repository.Auth{ 18 | Token: "asdf1234", 19 | } 20 | 21 | client, err := NewClient(context.Background(), auth) 22 | 23 | require.NoError(t, err) 24 | assert.NotNil(t, client) 25 | assert.Implements(t, (*repository.Client)(nil), client) 26 | }) 27 | 28 | t.Run("Test with invalid auth method", func(t *testing.T) { 29 | auth = &repository.Auth{ 30 | Username: "user", 31 | Password: "pass", 32 | } 33 | 34 | client, err := NewClient(context.Background(), auth) 35 | 36 | require.Error(t, err) 37 | assert.Nil(t, client) 38 | assert.Equal(t, "unsupported auth type: userpassword", err.Error()) 39 | }) 40 | 41 | t.Run("Test with nil auth method", func(t *testing.T) { 42 | auth = nil 43 | 44 | client, err := NewClient(context.Background(), auth) 45 | 46 | require.Error(t, err) 47 | assert.Nil(t, client) 48 | assert.Equal(t, "must provide authentication", err.Error()) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /v2/repository/github/commit_info_query.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/shurcooL/githubv4" 4 | 5 | // commitInfoQuery is the GraphQL query for retrieving GitHub CI/CD status info for a specific commit 6 | type commitInfoQuery struct { 7 | Resource struct { 8 | Typename string `graphql:"__typename"` 9 | Commit struct { 10 | URL string 11 | // External services can mark commits with a Status that is reflected in pull requests involving those commits 12 | Status struct { 13 | State statusState 14 | } 15 | AssociatedPullRequests struct { 16 | PageInfo struct { 17 | EndCursor githubv4.String 18 | HasNextPage bool 19 | } 20 | Nodes []pullRequest 21 | } `graphql:"associatedPullRequests(first: 100, after: $associatedPullRequestsCursor)"` 22 | // CheckSuites is a collection of the check runs created by a single GitHub App for a specific commit 23 | // More info on CheckSuites here: https://developer.github.com/v4/guides/intro-to-graphql/#discovering-the-graphql-api 24 | CheckSuites struct { 25 | PageInfo struct { 26 | EndCursor githubv4.String 27 | HasNextPage bool 28 | } 29 | Nodes []checkSuite 30 | } `graphql:"checkSuites(first: 100, after: $checkSuitesCursor)"` 31 | Signature struct { 32 | IsValid bool 33 | } 34 | Repository struct { 35 | URL string 36 | } 37 | } `graphql:"... on Commit"` 38 | } `graphql:"resource(url: $url)"` 39 | } 40 | 41 | // checkSuite is a collection of the check runs created by a CI/CD App 42 | type checkSuite struct { 43 | Status checkStatusState 44 | Conclusion checkConclusionState 45 | } 46 | 47 | // pullRequest contains the relevant information associated with a pull request 48 | type pullRequest struct { 49 | Merged bool 50 | MergeCommit commit 51 | BaseRefName string 52 | HeadRefName string 53 | URL string 54 | } 55 | -------------------------------------------------------------------------------- /v2/repository/github/consts.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | // previewSchemas contain the headers to access parts of GitHub's preview mode 4 | var previewSchemas = []string{ 5 | "application/vnd.github.groot-preview+json", 6 | "application/vnd.github.antiope-preview+json", 7 | } 8 | 9 | const ( 10 | // repositoryType is one of GitHub's GraphQL schema types representing a GitHub repository 11 | repositoryType = "Repository" 12 | // organizationType is one of GitHub's GraphQL schema types representing a GitHub organization 13 | organizationType = "Organization" 14 | // commitType is one of GitHub's GraphQL schema types representing a Git commit 15 | commitType = "Commit" 16 | // pullRequestType is one of GitHub's GraphQL schema types representing a Git pull request 17 | pullRequestType = "PullRequest" 18 | ) 19 | 20 | // pagination query limit 21 | const queryPageLimit = 3 22 | 23 | // checkConclusionState is a string that represents the state for a check suite or check run conclusion. 24 | // checkConclusionState is a type in the GitHub v4 GraphQL Schema 25 | type checkConclusionState string 26 | 27 | // checkStatusState is a string that represents the state for a check suite or check run status 28 | // checkStatusState is a type in the Github v4 GraphQL Schema 29 | type checkStatusState string 30 | 31 | // statusState is a string that represents the combined commit status 32 | // statusState is a type in the Github v4 GraphQL Schema 33 | type statusState string 34 | 35 | // pullRequestState is a string that represents the state for a pull request review 36 | // pullRequestReviewState is a type in the Github v4 GraphQL Schema 37 | type pullRequestReviewState string 38 | -------------------------------------------------------------------------------- /v2/repository/github/default_branch_query.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/shurcooL/githubv4" 4 | 5 | // defaultBranchQuery is the GraphQL query for retrieving information pertaining to the repository's default branch 6 | type defaultBranchQuery struct { 7 | Resource struct { 8 | Typename string `graphql:"__typename"` 9 | Repository struct { 10 | DefaultBranchRef struct { 11 | Name string 12 | Target struct { 13 | Commit struct { 14 | Typename string `graphql:"__typename"` 15 | History struct { 16 | PageInfo struct { 17 | EndCursor githubv4.String 18 | HasNextPage bool 19 | } 20 | Typename string `graphql:"__typename"` 21 | Nodes []commit // Nodes contains all of the commits in the default branch 22 | } `graphql:"history(first: 100, after: $defaultBranchCommitCursor)"` 23 | } `graphql:"... on Commit"` 24 | } 25 | } 26 | } `graphql:"... on Repository"` 27 | } `graphql:"resource(url: $url)"` 28 | } 29 | -------------------------------------------------------------------------------- /v2/repository/github/default_branch_result.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafeas/voucher/v2/repository" 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | // newDefaultBranchResult calls the defaultBranchQuery and populates the results with the respective variables 11 | func newDefaultBranchResult(ctx context.Context, ghc ghGraphQLClient, repoURL string) (repository.Branch, error) { 12 | formattedURI, err := createNewGitHubV4URI(repoURL) 13 | if err != nil { 14 | return repository.Branch{}, err 15 | } 16 | queryResult := new(defaultBranchQuery) 17 | allDefaultBranchCommits := make([]commit, 0) 18 | defaultBranchInfoVariables := map[string]interface{}{ 19 | "url": githubv4.URI(*formattedURI), 20 | "defaultBranchCommitCursor": (*githubv4.String)(nil), 21 | } 22 | 23 | err = paginationQuery(ctx, ghc, queryResult, defaultBranchInfoVariables, queryPageLimit, func(v interface{}) (bool, error) { 24 | dbq, ok := v.(*defaultBranchQuery) 25 | if !ok { 26 | return false, newTypeMismatchError("defaultBranchQuery", dbq) 27 | } 28 | resourceType := v.(*defaultBranchQuery).Resource.Typename 29 | if resourceType != repositoryType { 30 | return false, repository.NewTypeMismatchError(repositoryType, resourceType) 31 | } 32 | repo := dbq.Resource.Repository 33 | 34 | allDefaultBranchCommits = append(allDefaultBranchCommits, repo.DefaultBranchRef.Target.Commit.History.Nodes...) 35 | hasMoreResults := repo.DefaultBranchRef.Target.Commit.History.PageInfo.HasNextPage 36 | defaultBranchInfoVariables["defaultBranchCommitCursor"] = githubv4.NewString(repo.DefaultBranchRef.Target.Commit.History.PageInfo.EndCursor) 37 | return hasMoreResults, nil 38 | }) 39 | if err != nil { 40 | return repository.Branch{}, err 41 | } 42 | 43 | defaultBranchName := queryResult.Resource.Repository.DefaultBranchRef.Name 44 | commits := make([]repository.CommitRef, 0) 45 | for _, commit := range allDefaultBranchCommits { 46 | repoCommit := repository.NewCommitRef(commit.URL) 47 | commits = append(commits, repoCommit) 48 | } 49 | return repository.NewBranch(defaultBranchName, commits), nil 50 | } 51 | -------------------------------------------------------------------------------- /v2/repository/github/github_utils.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "net/url" 5 | "path" 6 | 7 | "github.com/grafeas/voucher/v2/repository" 8 | ) 9 | 10 | // GetCommitURL generates a commit url from the build metadata 11 | func GetCommitURL(b *repository.BuildDetail) (string, error) { 12 | repoMeta := repository.NewRepositoryMetadata(b.RepositoryURL) 13 | scheme, err := url.Parse(repoMeta.String()) 14 | if err != nil { 15 | return "", err 16 | } 17 | 18 | scheme.Path = path.Join( 19 | scheme.Path, 20 | "commit", 21 | b.Commit, 22 | ) 23 | return scheme.String(), nil 24 | } 25 | -------------------------------------------------------------------------------- /v2/repository/github/github_utils_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafeas/voucher/v2/repository" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetCommitURL(t *testing.T) { 11 | getCommitURLTests := []struct { 12 | expectedURL string 13 | mockBuildDetail *repository.BuildDetail 14 | }{ 15 | { 16 | expectedURL: "https://github.com/grafeas/voucher/commit/sl2o3vo2wojweoie", 17 | mockBuildDetail: &repository.BuildDetail{ 18 | RepositoryURL: "git@github.com/grafeas/voucher.git", 19 | Commit: "sl2o3vo2wojweoie", 20 | BuildCreator: "someone", 21 | BuildURL: "somebuild.url.io", 22 | }, 23 | }, 24 | } 25 | for _, test := range getCommitURLTests { 26 | commitURL, err := GetCommitURL(test.mockBuildDetail) 27 | assert.NoError(t, err, "error parsing github url") 28 | assert.EqualValues(t, test.expectedURL, commitURL, "commit url is not properly formatted") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /v2/repository/github/pull_request_reviews_query.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/shurcooL/githubv4" 4 | 5 | // pullRequestReviewsQuery is the GraphQL query for retrieving information pertaining to pull request reviews 6 | type pullRequestReviewsQuery struct { 7 | Resource struct { 8 | Typename string `graphql:"__typename"` 9 | PullRequest struct { 10 | Reviews struct { 11 | PageInfo struct { 12 | EndCursor githubv4.String 13 | HasNextPage bool 14 | } 15 | Nodes []review 16 | } `graphql:"reviews(first: 100, after: $reviewsCursor)"` 17 | } `graphql:"... on PullRequest"` 18 | } `graphql:"resource(url: $url)"` 19 | } 20 | 21 | type review struct { 22 | State pullRequestReviewState 23 | } 24 | -------------------------------------------------------------------------------- /v2/repository/github/pull_request_reviews_result.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafeas/voucher/v2/repository" 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | // getAllReviews is the graphQL query for collecting all reviews associated with a pull request 11 | func getAllReviews(ctx context.Context, ghc ghGraphQLClient, pullRequestURL string) ([]review, error) { 12 | allReviews := make([]review, 0) 13 | formattedURI, err := createNewGitHubV4URI(pullRequestURL) 14 | if err != nil { 15 | return nil, err 16 | } 17 | queryResult := new(pullRequestReviewsQuery) 18 | pullRequestVariables := map[string]interface{}{ 19 | "url": githubv4.URI(*formattedURI), 20 | "reviewsCursor": (*githubv4.String)(nil), 21 | } 22 | err = paginationQuery(ctx, ghc, queryResult, pullRequestVariables, queryPageLimit, func(v interface{}) (bool, error) { 23 | ciq, ok := v.(*pullRequestReviewsQuery) 24 | if !ok { 25 | return false, newTypeMismatchError("pullRequestReviewsQuery", ciq) 26 | } 27 | pullRequest := ciq.Resource.PullRequest 28 | resourceType := ciq.Resource.Typename 29 | if resourceType != pullRequestType { 30 | return false, repository.NewTypeMismatchError(commitType, resourceType) 31 | } 32 | 33 | allReviews = append(allReviews, pullRequest.Reviews.Nodes...) 34 | hasMoreResults := pullRequest.Reviews.PageInfo.HasNextPage 35 | pullRequestVariables["reviewsCursor"] = githubv4.NewString(pullRequest.Reviews.PageInfo.EndCursor) 36 | return hasMoreResults, nil 37 | }) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return allReviews, nil 42 | } 43 | -------------------------------------------------------------------------------- /v2/repository/github/pull_request_reviews_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestGetAllReviews(t *testing.T) { 12 | testCases := []struct { 13 | testName string 14 | pullRequestURL string 15 | input *pullRequestReviewsQuery 16 | mask []string 17 | queryPopulationVariables map[string]interface{} 18 | expected []review 19 | }{ 20 | { 21 | testName: "Testing zero associated reviews", 22 | pullRequestURL: "https://github.com/grafeas/voucher/v2/pull/64", 23 | input: func() *pullRequestReviewsQuery { 24 | res := new(pullRequestReviewsQuery) 25 | res.Resource.Typename = "PullRequest" 26 | res.Resource.PullRequest.Reviews.Nodes = []review{} 27 | res.Resource.PullRequest.Reviews.PageInfo.HasNextPage = false 28 | return res 29 | }(), 30 | mask: []string{ 31 | "Resource.Typename", 32 | "Resource.PullRequest.Reviews.Nodes", 33 | }, 34 | expected: []review{}, 35 | }, 36 | { 37 | testName: "Testing has associated reviews", 38 | pullRequestURL: "https://github.com/grafeas/voucher/v2/pull/23", 39 | input: func() *pullRequestReviewsQuery { 40 | res := new(pullRequestReviewsQuery) 41 | res.Resource.Typename = "PullRequest" 42 | res.Resource.PullRequest.Reviews.Nodes = []review{ 43 | {State: "Accepted"}, 44 | {State: "PENDING"}, 45 | } 46 | res.Resource.PullRequest.Reviews.PageInfo.HasNextPage = false 47 | return res 48 | }(), 49 | mask: []string{ 50 | "Resource.Typename", 51 | "Resource.PullRequest.Reviews.Nodes", 52 | }, 53 | expected: []review{ 54 | {State: "Accepted"}, 55 | {State: "PENDING"}, 56 | }, 57 | }, 58 | } 59 | for _, test := range testCases { 60 | t.Run(test.testName, func(t *testing.T) { 61 | c := new(mockGitHubGraphQLClient) 62 | c.HandlerFunc = createHandler(test.input, test.mask) 63 | require.Equal(t, pullRequestType, test.input.Resource.Typename) 64 | 65 | res, err := getAllReviews(context.Background(), c, test.pullRequestURL) 66 | assert.NoError(t, err, "Getting all associated reviews failed") 67 | assert.EqualValues(t, test.expected, res) 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /v2/repository/github/queries.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // queryHandler is called on every iteration of paginationQuery to populate a slice of query results 8 | // queryHandler checks to see whether there are more records given that GitHub has a limit of 100 records per query 9 | type queryHandler func(queryResult interface{}) (bool, error) 10 | 11 | // paginationQuery populates a destination slice with the appropriately typed query results 12 | // GitHub has a limit of 100 records so we must perform pagination 13 | func paginationQuery( 14 | ctx context.Context, 15 | ghc ghGraphQLClient, 16 | queryResult interface{}, 17 | queryPopulationVariables map[string]interface{}, 18 | pageLimit int, 19 | qh queryHandler, 20 | ) error { 21 | for i := 0; i < pageLimit; i++ { 22 | err := ghc.Query(ctx, queryResult, queryPopulationVariables) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | hasMoreResults, err := qh(queryResult) 28 | if nil != err { 29 | return err 30 | } 31 | 32 | if !hasMoreResults { 33 | return nil 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | // commit contains information pertaining to a commit 40 | type commit struct { 41 | URL string 42 | } 43 | -------------------------------------------------------------------------------- /v2/repository/github/repository_org_info_query.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | // repositoryOrgInfoQuery is the GraphQL query for retrieving GitHub repository and organizational info 4 | type repositoryOrgInfoQuery struct { 5 | Resource struct { 6 | Repository struct { 7 | Owner struct { 8 | Typename string `graphql:"__typename"` 9 | Organization struct { 10 | ID string 11 | Name string 12 | URL string 13 | } `graphql:"... on Organization"` 14 | } 15 | } `graphql:"... on Repository"` 16 | } `graphql:"resource(url: $url)"` 17 | } 18 | -------------------------------------------------------------------------------- /v2/repository/github/repository_org_info_result.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/grafeas/voucher/v2/repository" 9 | "github.com/shurcooL/githubv4" 10 | ) 11 | 12 | // newRepositoryOrgInfoResult calls the repositoryOrgInfoQuery and incorporates the respective variables 13 | func newRepositoryOrgInfoResult(ctx context.Context, ghc ghGraphQLClient, uri string) (repository.Organization, error) { 14 | formattedURI, err := createNewGitHubV4URI(uri) 15 | if err != nil { 16 | return repository.Organization{}, err 17 | } 18 | 19 | repoInfoVariables := map[string]interface{}{ 20 | "url": githubv4.URI(*formattedURI), 21 | } 22 | 23 | queryResult := new(repositoryOrgInfoQuery) 24 | if err := ghc.Query(ctx, queryResult, repoInfoVariables); err != nil { 25 | return repository.Organization{}, fmt.Errorf("RepositoryInfo query could not be completed. Error: %s", err) 26 | } 27 | if queryResult.Resource.Repository.Owner.Typename != organizationType { 28 | return repository.Organization{}, repository.NewTypeMismatchError(organizationType, queryResult.Resource.Repository.Owner.Typename) 29 | } 30 | organization := queryResult.Resource.Repository.Owner.Organization 31 | 32 | org := repository.NewOrganization(organization.Name, organization.URL) 33 | if org == nil { 34 | return repository.Organization{}, errors.New("error parsing url" + organization.URL) 35 | } 36 | 37 | return *org, nil 38 | } 39 | -------------------------------------------------------------------------------- /v2/repository/github/repository_org_info_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/grafeas/voucher/v2/repository" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNewRepositoryOrgInfoResult(t *testing.T) { 13 | newRepoOrgTests := []struct { 14 | testName string 15 | uri string 16 | input *repositoryOrgInfoQuery 17 | mask []string 18 | expected repository.Organization 19 | shouldError bool 20 | }{ 21 | { 22 | testName: "Testing happy path", 23 | uri: "https://github.com/grafeas/voucher", 24 | input: func() *repositoryOrgInfoQuery { 25 | res := new(repositoryOrgInfoQuery) 26 | res.Resource.Repository.Owner.Typename = "Organization" 27 | res.Resource.Repository.Owner.Organization.Name = "Shopify" 28 | res.Resource.Repository.Owner.Organization.URL = "https://github.com/Shopify" 29 | return res 30 | }(), 31 | mask: []string{"Resource.Repository.Owner.Typename", "Resource.Repository.Owner.Organization"}, 32 | expected: repository.Organization{ 33 | Alias: "Shopify", 34 | VCS: "github.com", 35 | Name: "Shopify", 36 | }, 37 | shouldError: false, 38 | }, 39 | { 40 | testName: "Testing with bad URL", 41 | uri: "hello@%a&%(.com", 42 | input: new(repositoryOrgInfoQuery), 43 | mask: []string{}, 44 | expected: repository.Organization{}, 45 | shouldError: true, 46 | }, 47 | } 48 | 49 | for _, test := range newRepoOrgTests { 50 | t.Run(test.testName, func(t *testing.T) { 51 | c := new(mockGitHubGraphQLClient) 52 | c.HandlerFunc = createHandler(test.input, test.mask) 53 | res, err := newRepositoryOrgInfoResult(context.Background(), c, test.uri) 54 | if test.shouldError { 55 | assert.Error(t, err) 56 | return 57 | } 58 | require.NoError(t, err) 59 | assert.EqualValues(t, test.expected, res) 60 | assert.Equal(t, organizationType, test.input.Resource.Repository.Owner.Typename) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /v2/repository/github/roundtripper.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "net/http" 4 | 5 | // roundTripperWrapper allows us to attach default headers to all githubv4 requests 6 | type roundTripperWrapper struct { 7 | roundTripper http.RoundTripper 8 | } 9 | 10 | // newRoundTripperWrapper creates a new RoundTripperWrapper 11 | func newRoundTripperWrapper(rt http.RoundTripper) *roundTripperWrapper { 12 | return &roundTripperWrapper{ 13 | roundTripper: rt, 14 | } 15 | } 16 | 17 | // addPreviewSchemaHeaders adds a given array of GitHub API preview schema headers 18 | // to an outgoing request 19 | func addPreviewSchemaHeaders(req *http.Request, previewSchemaHeaders []string) *http.Request { 20 | for _, header := range previewSchemaHeaders { 21 | req.Header.Add("Accept", header) 22 | } 23 | 24 | return req 25 | } 26 | 27 | // RoundTrip implements the http RoundTripper interface 28 | func (rtw *roundTripperWrapper) RoundTrip(req *http.Request) (*http.Response, error) { 29 | req = addPreviewSchemaHeaders(req, previewSchemas) 30 | return rtw.roundTripper.RoundTrip(req) 31 | } 32 | -------------------------------------------------------------------------------- /v2/repository/github/roundtripper_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewRoundTripperWrapper(t *testing.T) { 11 | client := &http.Client{} 12 | rtw := newRoundTripperWrapper(client.Transport) 13 | 14 | assert.Implements(t, (*http.RoundTripper)(nil), rtw) 15 | } 16 | 17 | func TestAddPreviewSchemaHeaders(t *testing.T) { 18 | headerTests := []struct { 19 | testName string 20 | headers []string 21 | expected http.Header 22 | }{ 23 | { 24 | testName: "Headers exist", 25 | headers: []string{"header1", "header2"}, 26 | expected: map[string][]string{ 27 | "Accept": {"header1", "header2"}, 28 | }, 29 | }, 30 | { 31 | testName: "Headers do not exist", 32 | headers: []string{}, 33 | expected: map[string][]string{}, 34 | }, 35 | { 36 | testName: "Parameters do not exist", 37 | headers: nil, 38 | expected: map[string][]string{}, 39 | }, 40 | } 41 | 42 | for _, test := range headerTests { 43 | t.Run(test.testName, func(t *testing.T) { 44 | req, err := http.NewRequest("GET", "https://github.com/", nil) 45 | 46 | assert.NoError(t, err) 47 | 48 | newReq := addPreviewSchemaHeaders(req, test.headers) 49 | assert.Exactly(t, newReq.Header, test.expected) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /v2/repository/mock_client.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | type MockClient struct { 10 | mock.Mock 11 | } 12 | 13 | func (m *MockClient) GetCommit(ctx context.Context, details BuildDetail) (Commit, error) { 14 | args := m.Called(ctx, details) 15 | return args.Get(0).(Commit), args.Error(1) 16 | } 17 | 18 | func (m *MockClient) GetOrganization(ctx context.Context, details BuildDetail) (Organization, error) { 19 | args := m.Called(ctx, details) 20 | return args.Get(0).(Organization), args.Error(1) 21 | } 22 | 23 | func (m *MockClient) GetBranch(ctx context.Context, details BuildDetail, name string) (Branch, error) { 24 | args := m.Called(ctx, details) 25 | return args.Get(0).(Branch), args.Error(1) 26 | } 27 | 28 | func (m *MockClient) GetDefaultBranch(ctx context.Context, details BuildDetail) (Branch, error) { 29 | args := m.Called(ctx, details) 30 | return args.Get(0).(Branch), args.Error(1) 31 | } 32 | -------------------------------------------------------------------------------- /v2/repository/objects_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewOrganization(t *testing.T) { 11 | cases := []struct { 12 | alias string 13 | url string 14 | expected Organization 15 | }{ 16 | { 17 | alias: "MyOrg", 18 | url: "https://github.com/organization", 19 | expected: Organization{ 20 | Alias: "MyOrg", 21 | Name: "organization", 22 | VCS: "github.com", 23 | }, 24 | }, 25 | { 26 | alias: "MyOrg2", 27 | url: "https://github.com", 28 | expected: Organization{ 29 | Alias: "MyOrg2", 30 | VCS: "github.com", 31 | }, 32 | }, 33 | { 34 | alias: "MyOrg3", 35 | url: "gitlab.com/Org/repo", 36 | expected: Organization{ 37 | Alias: "MyOrg3", 38 | VCS: "gitlab.com", 39 | Name: "Org", 40 | }, 41 | }, 42 | } 43 | 44 | for _, testCase := range cases { 45 | t.Run(testCase.alias, func(t *testing.T) { 46 | org := NewOrganization(testCase.alias, testCase.url) 47 | require.NotNil(t, org) 48 | assert.Equal(t, testCase.expected.Alias, org.Alias) 49 | assert.Equal(t, testCase.expected.Name, org.Name) 50 | assert.Equal(t, testCase.expected.VCS, org.VCS) 51 | }) 52 | } 53 | } 54 | 55 | func TestNewRepositoryMetadata(t *testing.T) { 56 | cases := []struct { 57 | url string 58 | expected Metadata 59 | }{ 60 | { 61 | url: "https://github.com/my-org/my-repo", 62 | expected: Metadata{ 63 | Name: "my-repo", 64 | Organization: "my-org", 65 | VCS: "github.com", 66 | }, 67 | }, 68 | { 69 | url: "https://github.com/my-org", 70 | expected: Metadata{ 71 | Organization: "my-org", 72 | VCS: "github.com", 73 | }, 74 | }, 75 | { 76 | url: "gitlab.com/", 77 | expected: Metadata{ 78 | VCS: "gitlab.com", 79 | }, 80 | }, 81 | { 82 | url: "git@github.com/my-org/my-repo.git", 83 | expected: Metadata{ 84 | Name: "my-repo", 85 | Organization: "my-org", 86 | VCS: "github.com", 87 | }, 88 | }, 89 | } 90 | 91 | for _, testCase := range cases { 92 | t.Run(testCase.url, func(t *testing.T) { 93 | metadata := NewRepositoryMetadata(testCase.url) 94 | assert.Equal(t, testCase.expected.Name, metadata.Name) 95 | assert.Equal(t, testCase.expected.Organization, metadata.Organization) 96 | assert.Equal(t, testCase.expected.VCS, metadata.VCS) 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /v2/repositorycheck.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import "github.com/grafeas/voucher/v2/repository" 4 | 5 | // RepositoryCheck represents a Voucher check that needs to lookup 6 | // information about an image from the repository that it's source code 7 | // is stored in. 8 | // 9 | // RepositoryCheck implements a MetadataCheck, as containers normally 10 | // do not contain information about their source repositories. This 11 | // enables us to take advantage of Grafeas (or other metadata systems) 12 | // which track build information for an image, in addition to signatures 13 | // and (possibly) vulnerability information. 14 | type RepositoryCheck interface { 15 | MetadataCheck 16 | SetRepositoryClient(repository.Client) 17 | } 18 | -------------------------------------------------------------------------------- /v2/request.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // Request describes the Voucher API request structure. 4 | type Request struct { 5 | ImageURL string `json:"image_url"` 6 | } 7 | -------------------------------------------------------------------------------- /v2/response.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import "github.com/docker/distribution/reference" 4 | 5 | // Response describes the response from a Check call. 6 | type Response struct { 7 | Image string `json:"image"` 8 | Success bool `json:"success"` 9 | Results []CheckResult `json:"results"` 10 | } 11 | 12 | // NewResponse creates a new Response for the passed ImageData, 13 | // with the passed results. 14 | func NewResponse(reference reference.Reference, results []CheckResult) (checkResponse Response) { 15 | checkResponse.Image = reference.String() 16 | checkResponse.Results = results 17 | checkResponse.Success = true 18 | 19 | for _, check := range checkResponse.Results { 20 | if !check.Success { 21 | checkResponse.Success = false 22 | break 23 | } 24 | } 25 | 26 | return checkResponse 27 | } 28 | -------------------------------------------------------------------------------- /v2/response_test.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | const responseJSON = `{ 12 | "image": "", 13 | "success": true, 14 | "results": [ 15 | { 16 | "name": "diy", 17 | "success": true, 18 | "attested": true, 19 | "details": { 20 | "Name": "attested", 21 | "Details": "signature here" 22 | } 23 | 24 | } 25 | ] 26 | }` 27 | 28 | func TestUnmarshalResponse(t *testing.T) { 29 | response := Response{} 30 | 31 | buf := bytes.NewBufferString(responseJSON) 32 | 33 | err := json.NewDecoder(buf).Decode(&response) 34 | assert.NoErrorf(t, err, "failed to unmarshal valid data: %s", err) 35 | } 36 | -------------------------------------------------------------------------------- /v2/result.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // CheckResult describes the result of a Check. If a check failed, it will have a 4 | // status of false. If a check succeeded, but its Attestation creation failed, 5 | // Success will be true, Attested will be false. Err will contain the first error to 6 | // occur. 7 | type CheckResult struct { 8 | ImageData ImageData `json:"-"` 9 | Name string `json:"name"` 10 | Err string `json:"error,omitempty"` 11 | Success bool `json:"success"` 12 | Attested bool `json:"attested"` 13 | Details interface{} `json:"details,omitempty"` 14 | } 15 | -------------------------------------------------------------------------------- /v2/scanner.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // MetadataScanner implements voucher.VulnerabilityScanner, and connects to Grafeas 8 | // to obtain vulnerability information. 9 | type MetadataScanner struct { 10 | failOn Severity 11 | client MetadataClient 12 | } 13 | 14 | // FailOn sets severity level that a vulnerability must match or exheed to 15 | // prompt a failure. 16 | func (s *MetadataScanner) FailOn(severity Severity) { 17 | s.failOn = severity 18 | } 19 | 20 | // Scan gets the vulnerabilities for an Image. 21 | func (s *MetadataScanner) Scan(ctx context.Context, i ImageData) ([]Vulnerability, error) { 22 | v, err := s.client.GetVulnerabilities(ctx, i) 23 | if nil != err { 24 | return []Vulnerability{}, err 25 | } 26 | vulns := make([]Vulnerability, 0, len(v)) 27 | for _, item := range v { 28 | if ShouldIncludeVulnerability(item, s.failOn) { 29 | vulns = append(vulns, item) 30 | } 31 | } 32 | return vulns, nil 33 | } 34 | 35 | // NewScanner creates a new MetadataScanner. 36 | func NewScanner(client MetadataClient) *MetadataScanner { 37 | scanner := new(MetadataScanner) 38 | scanner.client = client 39 | 40 | return scanner 41 | } 42 | -------------------------------------------------------------------------------- /v2/server/authentication.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | // isAuthorized returns true if the request's basic authentication header matches the 11 | // configured username and password. The password in the configuration is assumed to 12 | // have hashed using the bcrypt algorithm. 13 | func (s *Server) isAuthorized(r *http.Request) error { 14 | // If the server does not require auth, the user is always authorized. 15 | if !s.serverConfig.RequireAuth { 16 | return nil 17 | } 18 | 19 | if s.serverConfig.Username == "" || s.serverConfig.PassHash == "" { 20 | return errors.New("username or password misconfigured in configuration") 21 | } 22 | 23 | username, password, ok := r.BasicAuth() 24 | if ok { 25 | if username == s.serverConfig.Username { 26 | if err := bcrypt.CompareHashAndPassword([]byte(s.serverConfig.PassHash), []byte(password)); nil != err { 27 | return err 28 | } 29 | return nil 30 | } 31 | } 32 | 33 | return errors.New("user failed to authenticate, username and/or password is incorrect") 34 | } 35 | -------------------------------------------------------------------------------- /v2/server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // Config is a structure which contains Server configuration. 9 | type Config struct { 10 | Port int 11 | Timeout int 12 | RequireAuth bool 13 | Username string 14 | PassHash string 15 | } 16 | 17 | // Address is the address of the Server. 18 | func (config *Config) Address() string { 19 | return fmt.Sprintf(":%d", config.Port) 20 | } 21 | 22 | // TimeoutDuration returns the configured timeout for this Server. 23 | func (config *Config) TimeoutDuration() time.Duration { 24 | return time.Duration(config.Timeout) * time.Second 25 | } 26 | -------------------------------------------------------------------------------- /v2/server/handlers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // HandleCheckImage is a request handler that executes an individual Check or 11 | // all of the Checks in one CheckGroup and creates any attestations if 12 | // applicable. 13 | func (s *Server) HandleCheckImage(w http.ResponseWriter, r *http.Request) { 14 | var err error 15 | 16 | if err = s.isAuthorized(r); nil != err { 17 | http.Error(w, "username or password is incorrect", http.StatusUnauthorized) 18 | LogError("username or password is incorrect", err) 19 | return 20 | } 21 | 22 | variables := mux.Vars(r) 23 | checkName := variables["check"] 24 | 25 | if "" == checkName { 26 | http.Error(w, "failure", http.StatusInternalServerError) 27 | return 28 | } 29 | 30 | requiredChecks := []string{checkName} 31 | 32 | if s.HasCheckGroup(checkName) { 33 | requiredChecks = s.GetCheckGroup(checkName) 34 | } 35 | 36 | if err = verifiedRequiredChecksAreRegistered(requiredChecks...); err != nil { 37 | http.Error(w, fmt.Sprintf("check or group \"%s\" is not active: %s", checkName, err), http.StatusNotFound) 38 | return 39 | } 40 | 41 | s.handleChecks(w, r, requiredChecks...) 42 | } 43 | 44 | // HandleVerifyImage is a request handler that verifies an individual 45 | // attestation or all of the attestations which would be created by one 46 | // CheckGroup and creates any attestations if applicable. 47 | func (s *Server) HandleVerifyImage(w http.ResponseWriter, r *http.Request) { 48 | var err error 49 | 50 | if err = s.isAuthorized(r); nil != err { 51 | http.Error(w, "username or password is incorrect", 401) 52 | LogError("username or password is incorrect", err) 53 | return 54 | } 55 | 56 | variables := mux.Vars(r) 57 | checkName := variables["check"] 58 | 59 | if "" == checkName { 60 | http.Error(w, "failure", http.StatusInternalServerError) 61 | return 62 | } 63 | 64 | requiredChecks := []string{checkName} 65 | 66 | if s.HasCheckGroup(checkName) { 67 | requiredChecks = s.GetCheckGroup(checkName) 68 | } 69 | 70 | if err = verifiedRequiredChecksAreRegistered(requiredChecks...); err != nil { 71 | http.Error(w, fmt.Sprintf("check or group \"%s\" is not active: %s", checkName, err), http.StatusNotFound) 72 | return 73 | } 74 | 75 | s.handleVerify(w, r, requiredChecks...) 76 | } 77 | 78 | // HandleHealthCheck is a request handler that returns HTTP Status Code 200 79 | // when it is called. Can be used to determine uptime. 80 | func (s *Server) HandleHealthCheck(w http.ResponseWriter, r *http.Request) { 81 | w.WriteHeader(http.StatusOK) 82 | } 83 | -------------------------------------------------------------------------------- /v2/server/input.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | func handleInput(r *http.Request) (imageData voucher.ImageData, err error) { 11 | var request voucher.Request 12 | 13 | err = json.NewDecoder(r.Body).Decode(&request) 14 | if nil != err { 15 | return 16 | } 17 | 18 | imageData, err = voucher.NewImageData(request.ImageURL) 19 | return 20 | } 21 | -------------------------------------------------------------------------------- /v2/server/isenabled.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | func verifiedRequiredChecksAreRegistered(checks ...string) error { 11 | disabledChecks := make([]string, 0, len(checks)) 12 | for _, check := range checks { 13 | if !voucher.IsCheckFactoryRegistered(check) { 14 | disabledChecks = append(disabledChecks, check) 15 | } 16 | } 17 | 18 | if len(disabledChecks) != 0 { 19 | return fmt.Errorf("required check(s) are not registered: %s", strings.Join(disabledChecks, ", ")) 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /v2/server/log.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | log "github.com/sirupsen/logrus" 7 | 8 | voucher "github.com/grafeas/voucher/v2" 9 | ) 10 | 11 | func init() { 12 | log.SetFormatter(&log.JSONFormatter{}) 13 | } 14 | 15 | // LogRequests logs the request fields to stdout as Info 16 | func LogRequests(r *http.Request) { 17 | err := r.ParseForm() 18 | if err != nil { 19 | log.WithError(err).Info("received request with malformed form") 20 | return 21 | } 22 | 23 | log.WithFields(log.Fields{ 24 | "url": r.URL.String(), 25 | "path": r.URL.Path, 26 | "form": r.Form, 27 | }).Info("received request") 28 | } 29 | 30 | // LogResult logs each test run as Info 31 | func LogResult(response voucher.Response) { 32 | for _, result := range response.Results { 33 | log.WithFields(log.Fields{ 34 | "check": result.Name, 35 | "image": response.Image, 36 | "passed": result.Success, 37 | "attested": result.Attested, 38 | "error": result.Err, 39 | }).Info("Check Result") 40 | } 41 | } 42 | 43 | // LogError logs server errors to stdout as Error 44 | func LogError(message string, err error) { 45 | log.Errorf("Server error: %s: %s", message, err) 46 | } 47 | 48 | // LogWarning logs server errors to stdout as Warning 49 | func LogWarning(message string, err error) { 50 | log.Warningf("Server warning: %s: %s", message, err) 51 | } 52 | 53 | // LogInfo logs server information to stdout as Information. 54 | func LogInfo(message string) { 55 | log.Infof("Server info: %s", message) 56 | } 57 | -------------------------------------------------------------------------------- /v2/server/routes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | ) 8 | 9 | const ( 10 | healthCheckPath = "/services/ping" 11 | individualCheckPath = "/{check}" 12 | verifyCheckPath = individualCheckPath + "/verify" 13 | ) 14 | 15 | // Route stores metadata about a particular endpoint 16 | type Route struct { 17 | Name string 18 | Method string 19 | Path string 20 | HandlerFunc http.HandlerFunc 21 | } 22 | 23 | // NewRouter creates a mux router with the specified routes and handlers 24 | func NewRouter(s *Server) *mux.Router { 25 | router := mux.NewRouter().StrictSlash(true) 26 | for _, route := range getRoutes(s) { 27 | router. 28 | Methods(route.Method). 29 | Path(route.Path). 30 | Name(route.Name). 31 | Handler(route.HandlerFunc) 32 | } 33 | return router 34 | } 35 | 36 | func getRoutes(s *Server) []Route { 37 | return []Route{ 38 | { 39 | "Check Image", 40 | "POST", 41 | individualCheckPath, 42 | s.HandleCheckImage, 43 | }, 44 | { 45 | "Verify Image", 46 | "POST", 47 | verifyCheckPath, 48 | s.HandleVerifyImage, 49 | }, 50 | { 51 | "healthcheck: /services/ping", 52 | "GET", 53 | healthCheckPath, 54 | s.HandleHealthCheck, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /v2/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/grafeas/voucher/v2/cmd/config" 8 | "github.com/grafeas/voucher/v2/metrics" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type Server struct { 13 | serverConfig *Config 14 | checkGroups map[string][]string 15 | secrets *config.Secrets 16 | metrics metrics.Client 17 | } 18 | 19 | // NewServer creates a server on the specified port 20 | func NewServer(config *Config, secrets *config.Secrets, metrics metrics.Client) *Server { 21 | return &Server{ 22 | serverConfig: config, 23 | secrets: secrets, 24 | metrics: metrics, 25 | checkGroups: make(map[string][]string), 26 | } 27 | } 28 | 29 | // Serve runs the Server on the specified port 30 | func (server *Server) Serve() { 31 | router := NewRouter(server) 32 | log.Fatal(http.ListenAndServe(server.serverConfig.Address(), router)) 33 | } 34 | 35 | // SetCheckGroup adds a list of checks as a group with the passed name. 36 | func (server *Server) SetCheckGroup(name string, checkNames []string) { 37 | log.Infof("registering check group \"%s\": %s", name, strings.Join(checkNames, ", ")) 38 | server.checkGroups[name] = checkNames 39 | } 40 | 41 | // HasCheckGroup returns true if the Check Group with the passed name has been 42 | // registered with the server. 43 | func (server *Server) HasCheckGroup(name string) bool { 44 | _, ok := server.checkGroups[name] 45 | return ok 46 | } 47 | 48 | // GetCheckGroup returns a list of checks names that are in the check group 49 | // with the passed name. 50 | func (server *Server) GetCheckGroup(name string) []string { 51 | checks := server.checkGroups[name] 52 | return checks 53 | } 54 | -------------------------------------------------------------------------------- /v2/server/verify.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | voucher "github.com/grafeas/voucher/v2" 10 | "github.com/grafeas/voucher/v2/cmd/config" 11 | ) 12 | 13 | func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request, names ...string) { 14 | var imageData voucher.ImageData 15 | var err error 16 | 17 | defer r.Body.Close() 18 | 19 | w.Header().Set("content-type", "application/json") 20 | 21 | LogRequests(r) 22 | 23 | imageData, err = handleInput(r) 24 | if nil != err { 25 | http.Error(w, err.Error(), 422) 26 | LogError(err.Error(), err) 27 | return 28 | } 29 | 30 | ctx, cancel := context.WithTimeout(context.Background(), s.serverConfig.TimeoutDuration()) 31 | defer cancel() 32 | 33 | metadataClient, err := config.NewMetadataClient(ctx, s.secrets) 34 | if nil != err { 35 | http.Error(w, "server has been misconfigured", 500) 36 | LogError("failed to create MetadataClient", err) 37 | return 38 | } 39 | defer metadataClient.Close() 40 | 41 | attestations, err := metadataClient.GetAttestations(ctx, imageData) 42 | if nil != err { 43 | LogWarning(fmt.Sprintf("could not get image attestations for %s", imageData), err) 44 | } 45 | 46 | checkResponse := voucher.NewResponse( 47 | imageData, 48 | attestationsToResults(attestations, names), 49 | ) 50 | 51 | LogResult(checkResponse) 52 | 53 | err = json.NewEncoder(w).Encode(checkResponse) 54 | if nil != err { 55 | // if all else fails 56 | http.Error(w, err.Error(), 500) 57 | LogError("failed to encode respoonse as JSON", err) 58 | return 59 | } 60 | } 61 | 62 | func attestationsToResults(attestations []voucher.SignedAttestation, names []string) []voucher.CheckResult { 63 | results := make([]voucher.CheckResult, 0, len(names)) 64 | 65 | for _, name := range names { 66 | failed := true 67 | for _, attestation := range attestations { 68 | if attestation.CheckName == name { 69 | failed = false 70 | results = append(results, voucher.SignedAttestationToResult(attestation)) 71 | break 72 | } 73 | } 74 | if failed { 75 | results = append( 76 | results, 77 | voucher.CheckResult{ 78 | Name: name, 79 | Err: "", 80 | Success: false, 81 | Attested: false, 82 | Details: nil, 83 | }, 84 | ) 85 | } 86 | } 87 | 88 | return results 89 | } 90 | -------------------------------------------------------------------------------- /v2/severity.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import "fmt" 4 | 5 | // Severity is a integer that represents how severe a vulnerability 6 | // is. 7 | type Severity int 8 | 9 | // Severity constants, which represent the severities that we track. Other systems' 10 | // severities should be converted to one of the following. 11 | const ( 12 | NegligibleSeverity Severity = iota 13 | LowSeverity Severity = iota 14 | MediumSeverity Severity = iota 15 | UnknownSeverity Severity = iota 16 | HighSeverity Severity = iota 17 | CriticalSeverity Severity = iota 18 | ) 19 | 20 | const ( 21 | negligibleSeverityString = "negligible" 22 | lowSeverityString = "low" 23 | mediumSeverityString = "medium" 24 | highSeverityString = "high" 25 | criticalSeverityString = "critical" 26 | unknownSeverityString = "unknown" 27 | ) 28 | 29 | // String returns a string representation of a Severity. 30 | func (s Severity) String() string { 31 | switch s { 32 | case NegligibleSeverity: 33 | return negligibleSeverityString 34 | case LowSeverity: 35 | return lowSeverityString 36 | case MediumSeverity: 37 | return mediumSeverityString 38 | case HighSeverity: 39 | return highSeverityString 40 | case CriticalSeverity: 41 | return criticalSeverityString 42 | } 43 | return unknownSeverityString 44 | } 45 | 46 | // StringToSeverity returns the matching Severity to the passed string. 47 | // Returns an error if there isn't a matching Severity. 48 | func StringToSeverity(s string) (Severity, error) { 49 | switch s { 50 | case negligibleSeverityString: 51 | return NegligibleSeverity, nil 52 | case lowSeverityString: 53 | return LowSeverity, nil 54 | case mediumSeverityString: 55 | return MediumSeverity, nil 56 | case highSeverityString: 57 | return HighSeverity, nil 58 | case criticalSeverityString: 59 | return CriticalSeverity, nil 60 | case unknownSeverityString: 61 | return UnknownSeverity, nil 62 | } 63 | return UnknownSeverity, fmt.Errorf("severity %s doesn't exist", s) 64 | } 65 | -------------------------------------------------------------------------------- /v2/severity_test.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | var testSeverities = map[string]Severity{ 10 | negligibleSeverityString: NegligibleSeverity, 11 | lowSeverityString: LowSeverity, 12 | mediumSeverityString: MediumSeverity, 13 | unknownSeverityString: UnknownSeverity, 14 | highSeverityString: HighSeverity, 15 | criticalSeverityString: CriticalSeverity, 16 | "whatever": UnknownSeverity, 17 | } 18 | 19 | func TestSeverityToString(t *testing.T) { 20 | assert := assert.New(t) 21 | 22 | for expected, severity := range testSeverities { 23 | if "whatever" == expected { 24 | continue 25 | } 26 | value := severity.String() 27 | assert.Equalf(value, expected, "Severity.String() returned the wrong output, should be: %v, was %v", expected, value) 28 | } 29 | } 30 | 31 | func TestStringToSeverity(t *testing.T) { 32 | assert := assert.New(t) 33 | 34 | for name, expected := range testSeverities { 35 | value, err := StringToSeverity(name) 36 | 37 | if nil != err { 38 | assert.Equal(string(name), "whatever", "got error converting severities: ", err) 39 | continue 40 | } 41 | 42 | assert.Equalf(value, expected, "StringToSeverity returned the wrong Severity, should be: %v, was %v", expected, value) 43 | 44 | if "whatever" == name { 45 | assert.Error(err) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /v2/signer/errors.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // ErrNoKeyForCheck is the error returned when Voucher does not have a key 8 | // for the Check in question. 9 | var ErrNoKeyForCheck = errors.New("no signing entity exists for check") 10 | -------------------------------------------------------------------------------- /v2/signer/signer.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | type AttestationSigner interface { 4 | // Sign finds the key for a given check, signs the body and returns the signature and the key identifier 5 | Sign(checkName, body string) (string, string, error) 6 | Close() error 7 | } 8 | -------------------------------------------------------------------------------- /v2/subscriber/check.go: -------------------------------------------------------------------------------- 1 | package subscriber 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/distribution/reference" 7 | voucher "github.com/grafeas/voucher/v2" 8 | "github.com/grafeas/voucher/v2/cmd/config" 9 | "github.com/grafeas/voucher/v2/repository" 10 | ) 11 | 12 | // check runs all checks for a given image. 13 | // Returns true if the required check(s) have passed and true if the check run needs to be retried. 14 | func (s *Subscriber) check(canonicalImageReference reference.Canonical) (bool, bool) { 15 | var repositoryClient repository.Client 16 | var err error 17 | 18 | ctx, cancel := context.WithTimeout(context.Background(), s.cfg.TimeoutDuration()) 19 | defer cancel() 20 | 21 | metadataClient, err := config.NewMetadataClient(ctx, s.secrets) 22 | if nil != err { 23 | s.log.Errorf("failed to create MetadataClient: %s", err) 24 | return false, true 25 | } 26 | defer metadataClient.Close() 27 | 28 | buildDetail, err := metadataClient.GetBuildDetail(ctx, canonicalImageReference) 29 | if nil != err { 30 | s.log.Warningf("could not get image metadata for %s: %s", canonicalImageReference, err) 31 | } else { 32 | if s.secrets != nil { 33 | repositoryClient, err = config.NewRepositoryClient(ctx, s.secrets.RepositoryAuthentication, buildDetail.RepositoryURL) 34 | if nil != err { 35 | s.log.Warningf("failed to create repository client, continuing without git repo support: %s", err) 36 | } 37 | } else { 38 | s.log.Warning("failed to create repository client, no secrets configured") 39 | } 40 | } 41 | 42 | checksuite, err := config.NewCheckSuite(metadataClient, repositoryClient, s.cfg.RequiredChecks...) 43 | if nil != err { 44 | s.log.Errorf("failed to create CheckSuite: %s", err) 45 | return false, true 46 | } 47 | 48 | var results []voucher.CheckResult 49 | 50 | if s.cfg.DryRun { 51 | results = checksuite.Run(ctx, s.metrics, canonicalImageReference) 52 | } else { 53 | results = checksuite.RunAndAttest(ctx, metadataClient, s.metrics, canonicalImageReference) 54 | } 55 | 56 | checkResponse := voucher.NewResponse(canonicalImageReference, results) 57 | 58 | return checkResponse.Success, false 59 | } 60 | -------------------------------------------------------------------------------- /v2/subscriber/config.go: -------------------------------------------------------------------------------- 1 | package subscriber 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/grafeas/voucher/v2/server" 7 | ) 8 | 9 | // Config stores the necessary details for a Subscriber 10 | type Config struct { 11 | Server *server.Server 12 | Project string 13 | Subscription string 14 | RequiredChecks []string 15 | DryRun bool 16 | Timeout int 17 | } 18 | 19 | // TimeoutDuration returns the configured timeout for this Server. 20 | func (c *Config) TimeoutDuration() time.Duration { 21 | return time.Duration(c.Timeout) * time.Second 22 | } 23 | -------------------------------------------------------------------------------- /v2/subscriber/payload.go: -------------------------------------------------------------------------------- 1 | package subscriber 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/docker/distribution/reference" 8 | ) 9 | 10 | const insertAction string = "INSERT" 11 | 12 | var ( 13 | errNotInsertAction error = errors.New("ignoring; not an INSERT action") 14 | errNoDigest error = errors.New("no digest specified") 15 | errConversionFailed error = errors.New("error converting reference into type reference.Canonical") 16 | ) 17 | 18 | // Payload contains the information from the pub/sub message 19 | type Payload struct { 20 | Action string `json:"action"` 21 | Digest string `json:"digest"` 22 | Tag string `json:"tag,omitempty"` 23 | } 24 | 25 | // parsePayload parses data from a pubsub message. 26 | func parsePayload(message []byte) (*Payload, error) { 27 | var pl Payload 28 | 29 | err := json.Unmarshal(message, &pl) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if pl.Action != insertAction { 35 | return nil, errNotInsertAction 36 | } 37 | 38 | // an image without a digest is only tagged; opt to skip this image 39 | // as the digest version has already been pushed and will be processed soon 40 | if pl.Digest == "" { 41 | return nil, errNoDigest 42 | } 43 | 44 | return &pl, nil 45 | } 46 | 47 | func (p *Payload) asCanonicalImage() (reference.Canonical, error) { 48 | imageRef, err := reference.Parse(p.Digest) 49 | if nil != err { 50 | return nil, err 51 | } 52 | 53 | canonicalRef, ok := imageRef.(reference.Canonical) 54 | if !ok { 55 | return nil, errConversionFailed 56 | } 57 | 58 | return canonicalRef, nil 59 | } 60 | -------------------------------------------------------------------------------- /v2/subscriber/payload_test.go: -------------------------------------------------------------------------------- 1 | package subscriber 2 | 3 | import "testing" 4 | 5 | var payloadTestCases = []struct { 6 | name string 7 | rawPayload []byte 8 | expectedErr error 9 | expectedPayload *Payload 10 | }{ 11 | { 12 | "successfully parses INSERT payload", 13 | []byte(`{ "action":"INSERT", "digest": "some-digest-we-dont-validate" }`), 14 | nil, 15 | &Payload{Action: insertAction, Digest: "some-digest-dont-validate"}, 16 | }, 17 | { 18 | "returns nil payload with error for non-INSERT action payload", 19 | []byte(`{ "action":"DELETE" }`), 20 | errNotInsertAction, 21 | nil, 22 | }, 23 | { 24 | "returns an error when INSERT payload has no digest", 25 | []byte(`{ "action":"INSERT" }`), 26 | errNoDigest, 27 | nil, 28 | }, 29 | } 30 | 31 | func TestParsePayload(t *testing.T) { 32 | for _, tc := range payloadTestCases { 33 | t.Run(tc.name, func(t *testing.T) { 34 | pl, err := parsePayload(tc.rawPayload) 35 | 36 | if (pl == nil && tc.expectedPayload != nil) || (pl != nil && tc.expectedPayload == nil) { 37 | t.Errorf("unexpected payload: got %v, want %v", pl, tc.expectedPayload) 38 | } 39 | 40 | if err != tc.expectedErr { 41 | t.Errorf("error: got %v, want %v", err, tc.expectedErr) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /v2/testing/auth.go: -------------------------------------------------------------------------------- 1 | package vtesting 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "time" 8 | 9 | "github.com/docker/distribution/reference" 10 | "golang.org/x/oauth2" 11 | 12 | voucher "github.com/grafeas/voucher/v2" 13 | "github.com/grafeas/voucher/v2/auth" 14 | ) 15 | 16 | type testTokenSource struct { 17 | } 18 | 19 | func (tkSrc *testTokenSource) Token() (*oauth2.Token, error) { 20 | return &oauth2.Token{ 21 | AccessToken: "abcd", 22 | TokenType: "Bearer", 23 | RefreshToken: "", 24 | Expiry: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC), 25 | }, nil 26 | } 27 | 28 | type testAuth struct { 29 | server *httptest.Server 30 | } 31 | 32 | // GetTokenSource gets the default oauth2.TokenSource for connecting to OAuth2 33 | // protected systems, based on the runtime environment, or returns error if there's 34 | // an issue getting the token source. 35 | func (a *testAuth) GetTokenSource(ctx context.Context, ref reference.Named) (oauth2.TokenSource, error) { 36 | return new(testTokenSource), nil 37 | } 38 | 39 | // ToClient returns a new http.Client with the authentication details setup by 40 | // Auth.GetTokenSource. 41 | func (a *testAuth) ToClient(ctx context.Context, image reference.Named) (*http.Client, error) { 42 | if !a.IsForDomain(image) { 43 | return nil, auth.NewAuthError("does not match domain", image) 44 | } 45 | tokenSource, err := a.GetTokenSource(ctx, image) 46 | if nil != err { 47 | return nil, err 48 | } 49 | 50 | client := oauth2.NewClient(ctx, tokenSource) 51 | err = UpdateClient(client, a.server) 52 | 53 | return client, err 54 | } 55 | 56 | func (a *testAuth) IsForDomain(image reference.Named) bool { 57 | return reference.Domain(image) == "localhost" 58 | } 59 | 60 | // NewAuth creates a new Auth suitable for testing with. 61 | func NewAuth(server *httptest.Server) voucher.Auth { 62 | auth := new(testAuth) 63 | auth.server = server 64 | return auth 65 | } 66 | -------------------------------------------------------------------------------- /v2/testing/prepare_docker.go: -------------------------------------------------------------------------------- 1 | package vtesting 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/docker/distribution/reference" 10 | ) 11 | 12 | // PrepareDockerTest creates a new http.Client and httptest.Server for testing with. 13 | // The new client is created using the voucher tests specific Auth. 14 | func PrepareDockerTest(t *testing.T, ref reference.Named) (*http.Client, *httptest.Server) { 15 | t.Helper() 16 | 17 | server := NewTestDockerServer(t) 18 | 19 | auth := NewAuth(server) 20 | 21 | client, err := auth.ToClient(context.TODO(), ref) 22 | if nil != err { 23 | t.Fatalf("failed to create client for Docker API test: %s", err) 24 | } 25 | 26 | err = UpdateClient(client, server) 27 | if nil != err { 28 | t.Fatalf("failed to update client for Docker API test: %s", err) 29 | } 30 | 31 | return client, server 32 | } 33 | -------------------------------------------------------------------------------- /v2/testing/scanner.go: -------------------------------------------------------------------------------- 1 | package vtesting 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | voucher "github.com/grafeas/voucher/v2" 8 | ) 9 | 10 | type testVulnerabilityScanner struct { 11 | vulnerabilities []voucher.Vulnerability 12 | } 13 | 14 | // setVulnerabilitites sets the vulnerabilities to fail with. 15 | func (t *testVulnerabilityScanner) setVulnerabilities(vulnerabilities []voucher.Vulnerability) { 16 | t.vulnerabilities = vulnerabilities 17 | } 18 | 19 | // FailOn sets the minimum Severity to consider an image vulnerable. 20 | func (t *testVulnerabilityScanner) FailOn(failOn voucher.Severity) { 21 | // noop because the passed vulnerabilities will be the ones that are returned. 22 | } 23 | 24 | // Scan runs a scan against the passed ImageData and returns a slice of 25 | // Vulnerabilities. 26 | func (t *testVulnerabilityScanner) Scan(ctx context.Context, i voucher.ImageData) ([]voucher.Vulnerability, error) { 27 | return t.vulnerabilities, nil 28 | } 29 | 30 | // NewScanner creates a new Scanner suitable for testing with. The scanner will return 31 | // all of the vulnerabilities that were passed in, regardless of what FailOn is set to. If 32 | // the scanner is created with 0 vulnerabilities, Checks that use it will always pass. 33 | func NewScanner(t *testing.T, vulnerabilities ...voucher.Vulnerability) voucher.VulnerabilityScanner { 34 | scanner := new(testVulnerabilityScanner) 35 | scanner.setVulnerabilities(vulnerabilities) 36 | return scanner 37 | } 38 | -------------------------------------------------------------------------------- /v2/testing/server.go: -------------------------------------------------------------------------------- 1 | package vtesting 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | ) 12 | 13 | // UpdateClient updates the passed http.Client's Transport to support the 14 | // passed httptest.Server's server certificate, and to set a new Transport 15 | // which will override the request's hostname with the testing server's 16 | // hostname. 17 | func UpdateClient(client *http.Client, server *httptest.Server) error { 18 | serverURL, err := url.Parse(server.URL) 19 | if nil != err { 20 | return fmt.Errorf("failed to parse server URL: %s", err) 21 | } 22 | 23 | certificate := server.Certificate() 24 | if nil == certificate { 25 | return errors.New("no TLS certificate active") 26 | } 27 | 28 | certpool := x509.NewCertPool() 29 | certpool.AddCert(certificate) 30 | 31 | client.Transport = NewTransport(serverURL.Host, &http.Transport{ 32 | TLSClientConfig: &tls.Config{ 33 | RootCAs: certpool, 34 | }, 35 | }) 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /v2/testing/signer.go: -------------------------------------------------------------------------------- 1 | package vtesting 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/grafeas/voucher/v2/signer" 8 | "github.com/grafeas/voucher/v2/signer/pgp" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // NewPGPSigner creates a new signer using the test key in the testdata 14 | // directory. 15 | func NewPGPSigner(t *testing.T) signer.AttestationSigner { 16 | t.Helper() 17 | 18 | newKeyRing := pgp.NewKeyRing() 19 | 20 | keyFile, err := os.Open("../../testdata/testkey.asc") 21 | require.NoError(t, err, "failed to open key file") 22 | defer keyFile.Close() 23 | 24 | err = pgp.AddKeyToKeyRingFromReader(newKeyRing, "snakeoil", keyFile) 25 | require.NoError(t, err, "failed to add key to keyring") 26 | 27 | return newKeyRing 28 | } 29 | -------------------------------------------------------------------------------- /v2/testing/transport.go: -------------------------------------------------------------------------------- 1 | package vtesting 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // Transport wraps the http.Transport, but overwrites the URL of all requests 8 | // to point to the same path on the passed hostname. This is to enable us to 9 | // test connections to httptest.Server without changing the client code to 10 | // allow us to add ports to registry URLs (illegal in the reference.Reference 11 | // types in the docker registry library). 12 | type Transport struct { 13 | hostname string 14 | transport *http.Transport 15 | } 16 | 17 | // RoundTrip implements http.RoundTripper. It executes a HTTP request with 18 | // the Transport's internal http.Transport, but overrides the hostname before 19 | // doing so. 20 | func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { 21 | req.URL.Host = t.hostname 22 | return t.transport.RoundTrip(req) 23 | } 24 | 25 | // NewTransport creates a new Transport which wraps the http.Transport. 26 | // The purpose of this is to allow us to rewrite URLs to connect to our 27 | // test server. 28 | func NewTransport(hostname string, transport *http.Transport) *Transport { 29 | newTransport := new(Transport) 30 | newTransport.transport = transport 31 | newTransport.hostname = hostname 32 | return newTransport 33 | } 34 | -------------------------------------------------------------------------------- /v2/validrepocheck.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // RepoValidatorCheck represents a Voucher check that validates the passed 4 | // image is from a valid repo. 5 | type RepoValidatorCheck interface { 6 | Check 7 | SetValidRepos(repos []string) 8 | } 9 | -------------------------------------------------------------------------------- /v2/vulnerability.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // Vulnerability is a type that describes a security vulnerability. Third-party scanner vulnerabilities 4 | // should be converted to this type. 5 | type Vulnerability struct { 6 | Name string `json:"name"` // Name of the Vulnerability, or it's CVE number. 7 | Description string `json:"description"` // Description of the Vulnerability. 8 | Severity Severity `json:"severity"` // Severity of the Vulnerability. 9 | FixedBy string `json:"fixed_by"` // If this vulnerability was fixed, what it was fixed by. 10 | } 11 | 12 | // ShouldIncludeVulnerability returns true if the passed vulnerability should be included 13 | // in our vulnerability report. 14 | func ShouldIncludeVulnerability(test Vulnerability, baseline Severity) bool { 15 | return (test.Severity >= baseline) 16 | } 17 | -------------------------------------------------------------------------------- /v2/vulnerability_error.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import "fmt" 4 | 5 | // VulnerabilitiesError is an error that also contains a list of vulnerabilities. 6 | type VulnerabilitiesError struct { 7 | Vulnerabilities []Vulnerability 8 | } 9 | 10 | // Error returns the error message for the VulnerabilitiesError 11 | func (err VulnerabilitiesError) Error() string { 12 | output := fmt.Sprintf("vulnernable to %d vulnerabilities: ", len(err.Vulnerabilities)) 13 | 14 | for i, vulnerability := range err.Vulnerabilities { 15 | if i != 0 { 16 | output += ", " 17 | } 18 | output += fmt.Sprintf("%s (%s)", vulnerability.Name, vulnerability.Severity) 19 | } 20 | return output 21 | } 22 | 23 | // NewVulnerabilityError creates a new VulnerabilityError with the passed 24 | // Vulnerabilities. 25 | func NewVulnerabilityError(vuls []Vulnerability) (err error) { 26 | err = VulnerabilitiesError{ 27 | Vulnerabilities: vuls, 28 | } 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /v2/vulnerability_scanner.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // VulnerabilityScanner is an interface which represents a scanners that can be used 8 | // to check an image for vulnerabilities. VulnerabilityScanners implement the Scan 9 | // method, which takes ImageData as input and returns a slice of Vulnerabilities. 10 | type VulnerabilityScanner interface { 11 | 12 | // FailOn sets the minimum Severity to consider an image vulnerable. 13 | FailOn(Severity) 14 | 15 | // Scan runs a scan against the passed ImageData and returns a slice of 16 | // Vulnerabilities. 17 | Scan(context.Context, ImageData) ([]Vulnerability, error) 18 | } 19 | -------------------------------------------------------------------------------- /v2/vulnerability_test.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func makeTestVulns() []Vulnerability { 10 | return []Vulnerability{ 11 | { 12 | Name: "Bad One", 13 | Severity: HighSeverity, 14 | }, 15 | { 16 | Name: "Mediocre One", 17 | Severity: MediumSeverity, 18 | }, 19 | { 20 | Name: "The Rare One", 21 | Severity: CriticalSeverity, 22 | }, 23 | } 24 | } 25 | 26 | func TestVulnerabilityError(t *testing.T) { 27 | vulns := makeTestVulns() 28 | 29 | expected := "vulnernable to 3 vulnerabilities: Bad One (high), Mediocre One (medium), The Rare One (critical)" 30 | 31 | err := NewVulnerabilityError(vulns) 32 | assert.Error(t, err) 33 | assert.Equal(t, err.Error(), expected) 34 | } 35 | 36 | func TestShouldIncludeVulnerability(t *testing.T) { 37 | vulns := makeTestVulns() 38 | 39 | tests := []struct { 40 | Baseline Severity 41 | Compare Vulnerability 42 | Included bool 43 | }{ 44 | { 45 | Baseline: HighSeverity, 46 | Compare: vulns[0], 47 | Included: true, 48 | }, 49 | { 50 | Baseline: LowSeverity, 51 | Compare: vulns[0], 52 | Included: true, 53 | }, 54 | { 55 | Baseline: CriticalSeverity, 56 | Compare: vulns[0], 57 | Included: false, 58 | }, 59 | { 60 | Baseline: CriticalSeverity, 61 | Compare: vulns[1], 62 | Included: false, 63 | }, 64 | { 65 | Baseline: CriticalSeverity, 66 | Compare: vulns[2], 67 | Included: true, 68 | }, 69 | } 70 | 71 | for _, test := range tests { 72 | expected := "be" 73 | if !test.Included { 74 | expected = "not be" 75 | } 76 | 77 | assert.Equalf( 78 | t, 79 | ShouldIncludeVulnerability(test.Compare, test.Baseline), 80 | test.Included, 81 | "Test vulnerability is %s, should %s included when baseline is %s", vulns[0].Severity, expected, test.Baseline, 82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /v2/vulnerabilitycheck.go: -------------------------------------------------------------------------------- 1 | package voucher 2 | 3 | // VulnerabilityCheck represents a Voucher test. 4 | type VulnerabilityCheck interface { 5 | Check 6 | SetScanner(VulnerabilityScanner) 7 | } 8 | --------------------------------------------------------------------------------