diff --git a/.github/workflows/bash-checks.yaml b/.github/workflows/bash-checks.yaml new file mode 100644 index 0000000..0c4edae --- /dev/null +++ b/.github/workflows/bash-checks.yaml @@ -0,0 +1,21 @@ +name: bash-checks + +on: + pull_request: + + push: + branches: + - main + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Lint + run: docker-compose run --rm lint + + - name: Test + run: docker-compose run --rm tests diff --git a/.github/workflows/go-checks.yml b/.github/workflows/go-checks.yml new file mode 100644 index 0000000..f69a4ef --- /dev/null +++ b/.github/workflows/go-checks.yml @@ -0,0 +1,56 @@ +name: go-checks + +on: [push] + +defaults: + run: + working-directory: src + +jobs: + go-ensure-deps: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version-file: src/go.mod + cache: true + cache-dependency-path: src/go.sum + + - name: Check Go Modules + run: make ensure-deps + + go-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version-file: src/go.mod + + - name: Lint code + uses: golangci/golangci-lint-action@v3 + with: + version: v1.51.2 + working-directory: src + args: "-v --timeout=2m" + + go-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version-file: src/go.mod + cache: true + cache-dependency-path: src/go.sum + + - name: Test code + run: | + make test-ci diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..7596659 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,35 @@ +name: release-version + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v3 + with: + go-version-file: src/go.mod + cache: true + cache-dependency-path: src/go.sum + + - name: Run tests + run: make test + working-directory: src + + - name: Release Binaries + uses: goreleaser/goreleaser-action@v3 + with: + distribution: goreleaser + version: latest + args: release --clean --debug + workdir: src + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74344a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.envrc +# VIM swap files +*.swp +src/ecrscanresults +src/*.html diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4935450 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,63 @@ +issues: + exclude-rules: + - path: buildkite/agent.go + linters: + # type assertion does not need checking as this will run in linux only (via Docker) + - forcetypeassert + +linters: + enable-all: true + disable: + - gochecknoglobals + - wrapcheck + - varnamelen + - tagliatelle + - testpackage + - paralleltest + - gomnd + - goerr113 + - dupl + - forbidigo + - funlen + - unparam + - wsl + - errname + - exhaustivestruct + - exhaustruct + - nilnil + - nlreturn + - goconst + - lll + - asciicheck + - gocognit + - godot + - godox + - gofumpt + - nestif + - prealloc + - revive + # deprecated linters + - interfacer + - golint + - scopelint + - maligned + - deadcode + - ifshort + - structcheck + - nosnakecase + - varcheck + +linters-settings: + gosec: + # all rules enabled + # see also: https://securego.io/docs/rules/rule-intro.html + config: + global: + # allow #nosec override comments + nosec: enabled + # disable potentially noisy stricter audit mode + audit: disabled + G101: # "Look for hard-coded credentials" + mode: strict + cyclop: + max-complexity: 20 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..f3ab914 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.20 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c33f288 --- /dev/null +++ b/LICENSE @@ -0,0 +1,48 @@ +MIT License + +Copyright (c) 2021 Culture Amp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Some components derived from: https://github.com/monebag/monorepo-diff-buildkite-plugin +Incorporated under the terms of that project's license. + +MIT License + +Copyright (c) 2018 Silla Tan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..10dbdde --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# ECR Scan Results Buildkite Plugin + +Buildkite plugin to retrieve ECR scan results from AWS's ECR image scanning +service. By default the plugin will cause the step to fail if there are critical +or high vulnerabilities reported, but there are configurable thresholds on this +behaviour. + +> ℹ️ TIP: if you want the build to continue when vulnerabilities are found, be +> sure to supply values for `max-criticals` and `max-highs` parameters. If these +> are set to high values your build will never fail, but details will be +> supplied in the annotation. +> +> Check out the FAQs below for more information + +## Example + +Add the following lines to your `pipeline.yml`: + +```yml +steps: + - command: "command which creates an image" + # the docker-compose plugin may be used here instead of a command + plugins: + - cultureamp/ecr-scan-results#v1.2.0: + image-name: "$BUILD_REPO:deploy-$BUILD_TAG" +``` + +In a pipeline this will look something like: + +```yml +steps: + - label: ":docker: Build and push CDK deployment image" + command: "bin/ci_cdk_build_and_push.sh" + agents: + queue: ${BUILD_AGENT} + plugins: + - cultureamp/aws-assume-role: + role: ${BUILD_ROLE} + - cultureamp/ecr-scan-results#v1.2.0: + image-name: "$BUILD_REPO:deploy-$BUILD_TAG" +``` + +If you want the pipeline to pass with some vulnerabilities then set +`max-criticals` and `max-highs` like below. This pipeline will pass if there is +one critical vulenerability but fail if there are two. Similarly it will fail if +there are eleven high vulnerabilities. + +```yml +steps: + - label: ":docker: Build and push CDK deployment image" + command: "bin/ci_cdk_build_and_push.sh" + agents: + queue: ${BUILD_AGENT} + plugins: + - cultureamp/aws-assume-role: + role: ${BUILD_ROLE} + - cultureamp/ecr-scan-results#v1.2.0: + image-name: "$BUILD_REPO:deploy-$BUILD_TAG" + max-criticals: "1" + max-highs: "10" +``` + +## Configuration + +### `image-name` (Required, string) + +The name of the container image in ECR. This should be the same string that is +supplied as an arguement to the `docker push` command used to push the image to +AWS ECR. It should have the form: +`AWS_ACCOUNT_ID.dkr.ecr.REGION.amazonaws.com/REPOSITORY_NAME:IMAGE_TAG` with the +text in capitals replaced with the appropriate values for your environment. + +### `max-criticals` (Optional, string) + +If the number of critical vulnerabilities in the image exceeds this threshold +the build is failed. Defaults to 0. Use a sufficiently large number (e.g. 999) +to allow the build to always pass. + +### `max-highs` (Optional, string) + +If the number of high vulnerabilities in the image exceeds this threshold the +build is failed. Defaults to 0. Use a sufficiently large number (e.g. 999) to +allow the build to always pass. + +### `image-label` (Optional, string) + +When supplied, this is used to title the report annotation in place of the +repository name and tag. Useful sometimes when the repo name and tag make the +reports harder to scan visually. + +## Requirements + +### ECR Scan on Push + +This plugin assumes that the ECR repository has the `ScanOnPush` setting set (see +the [AWS +docs](https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html) +for more information). By default this is not set on AWS ECR repositories. +However `Base Infrastructure for Services` configures this for all repostories +that it creates so for `cultureamp` pipelines no change should be required. + +### Agent role requires the ecr:DescribeImages permission + +The Buildkite agent needs the AWS IAM `ecr:DescribeImages` permission to +retrieve the vulnerability scan counts. Culture Amp build-roles created by `Base +Infrastructure for Services` have all been modified to include this permission. + +### Scratch images are not supported + +ECR cannot scan scratch based images, and this should be OK as the underlying +container doesn't contain packages to scan. + +If this plugin is installed and pointed at a scratch image you may receive an +error and it may block the pipeline as a result. The error +`UnsupportedImageError` is expected in this scenario; see [the ECR +docs](https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-troubleshooting.html) +for more information. + +## FAQ + +### I have a vulnerability that isn't resolved yet, but I can wait on fixing. How do I do configure this plugin so I can unblock my builds? + +Refer to how to set your [max-criticals](https://github.com/cultureamp/ecr-scan-results-buildkite-plugin#max-criticals-optional-string), and [max-highs](https://github.com/cultureamp/ecr-scan-results-buildkite-plugin#max-highs-optional-string). + +### Are there guidelines on using up? + +Yes. Changing the `max-criticals` and `max-high` settings should not be taken lightly. + +This option is effectively a deferral of fixing the vulnerability. **Assess the situation first**. If the CVE describes a scenario that aligns with how your project is used, then you should be working to fix it rather than defer it. For help on this, check out the following the steps outlined [here](https://cultureamp.atlassian.net/wiki/spaces/PST/pages/2960916852/Central+SRE+Support+FAQs#I-have-high%2Fcritical-vulnerabilities-for-my-ECR-image%2C-and-its-blocking-my-builds.-What%E2%80%99s-going-on%3F). + +Below are some recommendations if you choose to exercise this option: + +1. Set the thresholds to the number of identified high or critical vulnerabilities. This is so you’re not permitting more vulnerabilities than you should. Especially for those you can fix by updating dependencies or packages. + +2. Set a scheduled reminder for your team to check if a fix is available for the CVE. If a fix is available, address it, and then lower your threshold for the respective vulnerability severity. diff --git a/catalog-info-component.yaml b/catalog-info-component.yaml new file mode 100644 index 0000000..8d5815b --- /dev/null +++ b/catalog-info-component.yaml @@ -0,0 +1,17 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ecr-scan-results-buildkite-plugin + description: | + Buildkite plugin to retrieve ECR scan results + tags: + - camp-sre + - data-internal-use-only + - users-internal + annotations: + github.com/project-slug: cultureamp/ecr-scan-results-buildkite-plugin + github.com/team-slug: cultureamp/sre-foundations +spec: + type: library + owner: sre-foundations + lifecycle: production diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 0000000..069a946 --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: ecr-scan-results-buildkite-plugin-location + tags: + - camp-foundations +spec: + targets: + - ./catalog-info-component.yaml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..935c491 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + lint: + image: buildkite/plugin-linter + command: ['--id', 'cultureamp/ecr-scan-results'] + volumes: + - ".:/plugin:ro" + + tests: + image: buildkite/plugin-tester:v4.0.0 + volumes: + - ".:/plugin" diff --git a/hooks/post-command b/hooks/post-command new file mode 100755 index 0000000..99e415b --- /dev/null +++ b/hooks/post-command @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail + +dir="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" + +# shellcheck source=lib/download.bash +. "$dir/../lib/download.bash" + +download_binary_and_run "$@" || exit 1 diff --git a/lib/download.bash b/lib/download.bash new file mode 100644 index 0000000..40fe618 --- /dev/null +++ b/lib/download.bash @@ -0,0 +1,99 @@ +# Download logic based on that used by https://github.com/monebag/monorepo-diff-buildkite-plugin +# Used under the terms of that license. + +check_cmd() { + command -v "$1" > /dev/null 2>&1 + return $? +} + +say() { + echo "$1" +} + +err() { + local red;red=$(tput setaf 1 2>/dev/null || echo '') + local reset;reset=$(tput sgr0 2>/dev/null || echo '') + say "${red}ERROR${reset}: $1" >&2 + exit 1 +} + +get_architecture() { + local _ostype;_ostype="$(uname -s | tr '[:upper:]' '[:lower:]')" + local _arch;_arch="$(uname -m)" + local _arm=("arm armhf aarch64 aarch64_be armv6l armv7l armv8l arm64e") # arm64 + local _amd=("x86 x86pc i386 i686 i686-64 x64 x86_64 x86_64h athlon") # amd64 + + if [[ "${_arm[*]}" =~ ${_arch} ]]; then + _arch="arm64" + elif [[ "${_amd[*]}" =~ ${_arch} ]]; then + _arch="amd64" + elif [[ "${_arch}" != "ppc64le" ]]; then + echo -e "ERROR: unsupported architecture \"${_arch}\"" >&2 + exit 2 + fi + + RETVAL="${_ostype}_${_arch}" +} + +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +# This wraps curl or wget. +# Try curl first, if not installed, use wget instead. +downloader() { + if check_cmd curl; then + _dld=curl + elif check_cmd wget; then + _dld=wget + else + _dld='curl or wget' # to be used in error message of need_cmd + fi + + if [ "$1" = --check ]; then + need_cmd "$_dld" + elif [ "$_dld" = curl ]; then + curl -sSfL "$1" -o "$2" + elif [ "$_dld" = wget ]; then + wget "$1" -O "$2" + else + err "Unknown downloader" + fi +} + +get_version() { + local _plugin=${BUILDKITE_PLUGINS:-""} + local _version;_version=$(echo "$_plugin" | sed -e 's/.*ecr-scan-results-buildkite-plugin//' -e 's/\".*//') + RETVAL="$_version" +} + +download_binary_and_run() { + get_architecture || return 1 + local _arch="$RETVAL" + local _executable="ecr-scan-results-buildkite-plugin" + local _repo="https://github.com/cultureamp/ecr-scan-results-buildkite-plugin" + + get_version || return 1 + local _version="$RETVAL" + + if [ -z "${_version}" ]; then + _url=${_repo}/releases/latest/download/${_executable}_${_arch} + else + _url=${_repo}/releases/download/${_version:1}/${_executable}_${_arch} + fi + +# local test_mode="${BUILDKITE_PLUGIN_ECR_SCAN_RESULTS_BUILDKITE_PLUGIN_TEST_MODE:-false}" + +# if [[ "$test_mode" == "false" ]]; then + if ! downloader "$_url" "$_executable"; then + say "failed to download $_url" + exit 1 + fi + + chmod +x ${_executable} +# fi + + ./${_executable} +} diff --git a/plugin.yml b/plugin.yml new file mode 100644 index 0000000..fb8d767 --- /dev/null +++ b/plugin.yml @@ -0,0 +1,20 @@ +name: ECR Scan Results +description: > + Retrieves vulnerability scan results from ECR, creating a report as an + annotation on the build. Will fail the step if vulnerabilities exist (though + this is configurable). +author: https://github.com/cultureamp +requirements: + - docker + - jq +configuration: + properties: + image-name: + type: string + max-criticals: + type: string + max-highs: + type: string + image-label: + type: string + additionalProperties: false diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml new file mode 100644 index 0000000..5c67f83 --- /dev/null +++ b/src/.goreleaser.yaml @@ -0,0 +1,28 @@ +builds: + - binary: ecr-scan-results-buildkite-plugin + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - "386" + - amd64 + - arm + - arm64 + - ppc64le + +checksum: + name_template: 'checksums.txt' + +archives: + - format: 'binary' + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + use: github-native + +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..07264ed --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,30 @@ +ARG BUILDER_IMAGE=public.ecr.aws/docker/library/golang:1.18-alpine +ARG DISTROLESS_IMAGE=gcr.io/distroless/static + +FROM ${BUILDER_IMAGE} as builder + +# Ensure ca-certficates are up to date +RUN update-ca-certificates + +WORKDIR /src + +COPY go.mod . + +RUN go mod download +RUN go mod verify + +COPY . ./ + +# build as a static binary without debug symbols +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags='-w -s -extldflags "-static"' -a \ + -o /dist/ecrscanresults . + +# runtime image using static distroless base +# using static nonroot image +# user:group is nobody:nobody, uid:gid = 65534:65534 +FROM ${DISTROLESS_IMAGE} + +COPY --from=builder /dist/ecrscanresults /ecrscanresults + +ENTRYPOINT ["/ecrscanresults"] diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..c9c4890 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,19 @@ +.PHONY: mod +mod: + go mod download + +.PHONY: test +test: mod + go test -race -cover ./... + +.PHONY: test-ci +test-ci: mod + mkdir artifacts + go test ./... -covermode=atomic -coverprofile=artifacts/count.out + go tool cover -func=artifacts/count.out | tee artifacts/coverage.out + +# ensures that `go mod tidy` has been run after any dependency changes +.PHONY: ensure-deps +ensure-deps: mod + @go mod tidy + @git diff --exit-code diff --git a/src/buildkite/agent.go b/src/buildkite/agent.go new file mode 100644 index 0000000..a5ab246 --- /dev/null +++ b/src/buildkite/agent.go @@ -0,0 +1,63 @@ +package buildkite + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + osexec "golang.org/x/sys/execabs" +) + +type Agent struct { +} + +func (a Agent) Annotate(ctx context.Context, message string, style string, annotationContext string) error { + return execCmd(ctx, "buildkite-agent", &message, "annotate", "--style", style, "--context", annotationContext) +} + +func (a Agent) ArtifactUpload(ctx context.Context, path string) error { + return execCmd(ctx, "buildkite-agent", nil, "artifact", "upload", path) +} + +func execCmd(ctx context.Context, executableName string, stdin *string, args ...string) error { + Logf("Executing: %s %s\n", executableName, strings.Join(args, " ")) + + cmd := osexec.CommandContext(ctx, executableName, args...) + + if stdin != nil { + cmd.Stdin = strings.NewReader(*stdin) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Relay incoming signals to the executing command. + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan) + + if err := cmd.Start(); err != nil { + return err + } + + go func() { + for { + sig := <-sigChan + _ = cmd.Process.Signal(sig) + } + }() + + if err := cmd.Wait(); err != nil { + _ = cmd.Process.Signal(os.Kill) + return fmt.Errorf("failed to wait for command termination: %w", err) + } + + waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) + exitStatus := waitStatus.ExitStatus() + if exitStatus != 0 { + return fmt.Errorf("command exited with non-zero status: %d", exitStatus) + } + + return nil +} diff --git a/src/buildkite/log.go b/src/buildkite/log.go new file mode 100644 index 0000000..3b2de42 --- /dev/null +++ b/src/buildkite/log.go @@ -0,0 +1,31 @@ +package buildkite + +import ( + "fmt" +) + +func LogGroup(message string) { + fmt.Printf("--- %s\n", message) +} + +func LogGroupf(format string, a ...any) { + LogGroup(fmt.Sprintf(format, a...)) +} + +func LogGroupClosed(message string) { + fmt.Printf("+++ %s\n", message) +} + +func Log(message string) { + fmt.Println(message) +} + +func Logf(format string, a ...any) { + fmt.Printf(format, a...) +} + +func LogFailuref(format string, a ...any) { + // make sure the current group is expanded + fmt.Println("^^^ +++") + fmt.Printf(format, a...) +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..99ccc88 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,43 @@ +module github.com/cultureamp/ecrscanresults + +go 1.20 + +require ( + github.com/MarvinJWendt/testza v0.4.1 + github.com/aws/aws-sdk-go-v2 v1.17.7 + github.com/aws/aws-sdk-go-v2/config v1.18.19 + github.com/aws/aws-sdk-go-v2/service/ecr v1.18.7 + github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d + github.com/kelseyhightower/envconfig v1.4.0 + github.com/stretchr/testify v1.7.1 +) + +require github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect + +require ( + github.com/atomicgo/cursor v0.0.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect + github.com/aws/smithy-go v1.13.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gookit/color v1.5.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666 // indirect + github.com/klauspost/cpuid/v2 v2.0.12 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pterm/pterm v0.12.41 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sergi/go-diff v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect + golang.org/x/sys v0.6.0 + golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect + golang.org/x/text v0.8.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..2a81a83 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,113 @@ +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.3.5/go.mod h1:ExbTpWmA1z2E9HSskvrNcwApoX4F9bID692s10nuHRY= +github.com/MarvinJWendt/testza v0.4.1 h1:bqidLqFVtySvyq7D+xIfFKefl+AfJtDpivXC9fx3hm4= +github.com/MarvinJWendt/testza v0.4.1/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/aws/aws-sdk-go-v2 v1.17.7 h1:CLSjnhJSTSogvqUGhIC6LqFKATMRexcxLZ0i/Nzk9Eg= +github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/config v1.18.19 h1:AqFK6zFNtq4i1EYu+eC7lcKHYnZagMn6SW171la0bGw= +github.com/aws/aws-sdk-go-v2/config v1.18.19/go.mod h1:XvTmGMY8d52ougvakOv1RpiTLPz9dlG/OQHsKU/cMmY= +github.com/aws/aws-sdk-go-v2/credentials v1.13.18 h1:EQMdtHwz0ILTW1hoP+EwuWhwCG1hD6l3+RWFQABET4c= +github.com/aws/aws-sdk-go-v2/credentials v1.13.18/go.mod h1:vnwlwjIe+3XJPBYKu1et30ZPABG3VaXJYr8ryohpIyM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 h1:gt57MN3liKiyGopcqgNzJb2+d9MJaKT/q1OksHNXVE4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1/go.mod h1:lfUx8puBRdM5lVVMQlwt2v+ofiG/X6Ms+dy0UkG/kXw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 h1:sJLYcS+eZn5EeNINGHSCRAwUJMFVqklwkH36Vbyai7M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31/go.mod h1:QT0BqUvX1Bh2ABdTGnjqEjvjzrCfIniM9Sc8zn9Yndo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 h1:1mnRASEKnkqsntcxHaysxwgVoUUp5dkiB+l3llKnqyg= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25/go.mod h1:zBHOPwhBc3FlQjQJE/D3IfPWiWaQmT06Vq9aNukDo0k= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 h1:p5luUImdIqywn6JpQsW3tq5GNOxKmOnEpybzPx+d1lk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32/go.mod h1:XGhIBZDEgfqmFIugclZ6FU7v75nHhBDtzuB4xB/tEi4= +github.com/aws/aws-sdk-go-v2/service/ecr v1.18.7 h1:oQ1Esut3iaL2Dydt2RBd9gbuUevToXpdTI+Uh1xXryI= +github.com/aws/aws-sdk-go-v2/service/ecr v1.18.7/go.mod h1:RHhgOMnMIkgB4TmxQat9obSnZ6fF1fuA27+itZKUi1o= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 h1:5LHn8JQ0qvjD9L9JhMtylnkcw7j05GDZqM9Oin6hpr0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25/go.mod h1:/95IA+0lMnzW6XzqYJRpjjsAbKEORVeO0anQqjd2CNU= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 h1:5V7DWLBd7wTELVz5bPpwzYy/sikk0gsgZfj40X+l5OI= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.6/go.mod h1:Y1VOmit/Fn6Tz1uFAeCO6Q7M2fmfXSCLeL5INVYsLuY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 h1:B8cauxOH1W1v7rd8RdI/MWnoR4Ze0wIHWrb90qczxj4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6/go.mod h1:Lh/bc9XUf8CfOY6Jp5aIkQtN+j1mc+nExc+KXj9jx2s= +github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 h1:bWNgNdRko2x6gqa0blfATqAZKZokPIeM1vfmQt2pnvM= +github.com/aws/aws-sdk-go-v2/service/sts v1.18.7/go.mod h1:JuTnSoeePXmMVe9G8NcjjwgOKEfZ4cOjMuT2IBT/2eI= +github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= +github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0 h1:1Opow3+BWDwqor78DcJkJCIwnkviFi+rrOANki9BUFw= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666 h1:abLciEiilfMf19Q1TFWDrp9j5z5one60dnnpvc6eabg= +github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666/go.mod h1:xqGOmDZzLOG7+q/CgsbXv10g4tgPsbjhmAxyaTJMvis= +github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d h1:qtCcYJK2bebPXEC8Wy+enYxQqmWnT6jlVTHnDGpwvkc= +github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d/go.mod h1:U7FWcK1jzZJnYuSnxP6efX3ZoHbK1CEpD0ThYyGNPNI= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/pterm/pterm v0.12.41 h1:e2BRfFo1H9nL8GY0S3ImbZqfZ/YimOk9XtkhoobKJVs= +github.com/pterm/pterm v0.12.41/go.mod h1:LW/G4J2A42XlTaPTAGRPvbBfF4UXvHWhC6SN7ueU4jU= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..b0a1ca1 --- /dev/null +++ b/src/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/cultureamp/ecrscanresults/buildkite" + "github.com/cultureamp/ecrscanresults/registry" + "github.com/cultureamp/ecrscanresults/report" + "github.com/cultureamp/ecrscanresults/runtimeerrors" + "github.com/kelseyhightower/envconfig" +) + +const pluginEnvironmentPrefix = "BUILDKITE_PLUGIN_ECR_SCAN_RESULTS" + +type Config struct { + Repository string `envconfig:"IMAGE_NAME" split_words:"true" required:"true"` + ImageLabel string `envconfig:"IMAGE_LABEL" split_words:"true"` + CriticalSeverityThreshold int32 `envconfig:"MAX_CRITICALS" split_words:"true"` + HighSeverityThreshold int32 `envconfig:"MAX_HIGHS" split_words:"true"` +} + +func main() { + var pluginConfig Config + if err := envconfig.Process(pluginEnvironmentPrefix, &pluginConfig); err != nil { + buildkite.LogFailuref("plugin configuration error: %s\n", err.Error()) + os.Exit(1) + } + if pluginConfig.CriticalSeverityThreshold < 0 { + buildkite.LogFailuref("max-criticals must be greater than or equal to 0") + os.Exit(1) + } + if pluginConfig.HighSeverityThreshold < 0 { + buildkite.LogFailuref("max-highs must be greater than or equal to 0") + os.Exit(1) + } + + ctx := context.Background() + agent := buildkite.Agent{} + + err := runCommand(ctx, pluginConfig, agent) + if err != nil { + buildkite.LogFailuref("plugin execution failed: %s\n", err.Error()) + + // For this plugin, we don't want to block the build on most errors: + // scan access and availability can be quite flakey. For this reason, we + // wrap most issues in a non-fatal error type. + if runtimeerrors.IsFatal(err) { + os.Exit(1) + } else { + // Attempt to annotate the build with the issue, but it's OK if the + // annotation fails. We annotate to notify the user of the issue, + // otherwise it would be lost in the log. + annotation := fmt.Sprintf("ECR scan results plugin could not create a result for the image %s", "") + _ = agent.Annotate(ctx, annotation, "error", hash(pluginConfig.Repository)) + } + } +} + +func runCommand(ctx context.Context, pluginConfig Config, agent buildkite.Agent) error { + buildkite.Logf("Scan results report requested for %s\n", pluginConfig.Repository) + buildkite.Logf("Thresholds: criticals %d highs %d\n", pluginConfig.CriticalSeverityThreshold, pluginConfig.HighSeverityThreshold) + + imageID, err := registry.RegistryInfoFromURL(pluginConfig.Repository) + if err != nil { + return err + } + + awsConfig, err := config.LoadDefaultConfig(ctx, config.WithRegion(imageID.Region)) + if err != nil { + return runtimeerrors.NonFatal("could not configure AWS access", err) + } + + scan, err := registry.NewRegistryScan(awsConfig) + if err != nil { + return runtimeerrors.NonFatal("could not set up ECR access", err) + } + + buildkite.Logf("Getting image digest for %s\n", imageID) + imageDigest, err := scan.GetLabelDigest(ctx, imageID) + if err != nil { + return runtimeerrors.NonFatal("could not find digest for image", err) + } + + buildkite.Logf("Digest: %s\n", imageDigest) + + buildkite.LogGroupf(":ecr: Creating ECR scan results report for %s\n", imageID) + err = scan.WaitForScanFindings(ctx, imageDigest) + if err != nil { + return runtimeerrors.NonFatal("could not retrieve scan results", err) + } + + buildkite.Log("report ready, retrieving ...") + + findings, err := scan.GetScanFindings(ctx, imageDigest) + if err != nil { + return runtimeerrors.NonFatal("could not retrieve scan results", err) + } + + buildkite.Logf("retrieved. %d findings in report.\n", len(findings.ImageScanFindings.Findings)) + + criticalFindings := findings.ImageScanFindings.FindingSeverityCounts["CRITICAL"] + highFindings := findings.ImageScanFindings.FindingSeverityCounts["HIGH"] + overThreshold := + criticalFindings > pluginConfig.CriticalSeverityThreshold || + highFindings > pluginConfig.HighSeverityThreshold + + buildkite.Logf("Severity counts: critical=%d high=%d overThreshold=%v\n", criticalFindings, highFindings, overThreshold) + + buildkite.Log("Creating report annotation...") + annotationCtx := report.AnnotationContext{ + Image: imageID, + ImageLabel: pluginConfig.ImageLabel, + ScanFindings: *findings.ImageScanFindings, + CriticalSeverityThreshold: pluginConfig.CriticalSeverityThreshold, + HighSeverityThreshold: pluginConfig.HighSeverityThreshold, + } + + annotation, err := annotationCtx.Render() + if err != nil { + return runtimeerrors.NonFatal("could not render report", err) + } + buildkite.Log("done.") + + annotationStyle := "info" + if overThreshold { + annotationStyle = "error" + } else if criticalFindings > 0 || highFindings > 0 { + annotationStyle = "warning" + } + + err = agent.Annotate(ctx, string(annotation), annotationStyle, "scan_results_"+imageDigest.Tag) + if err != nil { + return runtimeerrors.NonFatal("could not annotate build", err) + } + + buildkite.Log("Uploading report as an artifact...") + filename := fmt.Sprintf("result.%s.html", strings.TrimPrefix(imageDigest.Tag, "sha256:")) + err = os.WriteFile(filename, annotation, fs.ModePerm) + if err != nil { + return runtimeerrors.NonFatal("could not write report artifact", err) + } + + err = agent.ArtifactUpload(ctx, "result*.html") + if err != nil { + return runtimeerrors.NonFatal("could not upload report artifact", err) + } + + buildkite.Log("done.") + + // exceeding threshold is a fatal error + if overThreshold { + return errors.New("vulnerability threshold exceeded") + } + + return nil +} + +func hash(data ...string) string { + h := sha256.New() + for _, d := range data { + h.Write([]byte(d)) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/src/registry/ecr.go b/src/registry/ecr.go new file mode 100644 index 0000000..76d7a95 --- /dev/null +++ b/src/registry/ecr.go @@ -0,0 +1,148 @@ +package registry + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" +) + +var registryImageExpr = regexp.MustCompile(`^(?P[^.]+)\.dkr\.ecr\.(?P[^.]+).amazonaws.com/(?P[^:]+)(?::(?P.+))?$`) + +type RegistryInfo struct { + // RegistryID is the AWS ECR account ID of the source registry. + RegistryID string + // Region is the AWS region of the registry. + Region string + // Name is the ECR repository name. + Name string + // Tag is the image label or an image digest. + Tag string +} + +func (i RegistryInfo) String() string { + return fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s:%s", i.RegistryID, i.Region, i.Name, i.Tag) +} + +func RegistryInfoFromURL(url string) (RegistryInfo, error) { + info := RegistryInfo{} + names := registryImageExpr.SubexpNames() + match := registryImageExpr.FindStringSubmatch(url) + if match == nil { + return info, fmt.Errorf("invalid registry URL: %s", url) + } + + // build the struct using the named subexpressions from the expression + for i, value := range match { + nm := names[i] + switch nm { + case "registryId": + info.RegistryID = value + case "region": + info.Region = value + case "repoName": + info.Name = value + case "tag": + info.Tag = value + } + } + + return info, nil +} + +type RegistryScan struct { + Client *ecr.Client +} + +func NewRegistryScan(config aws.Config) (*RegistryScan, error) { + client := ecr.NewFromConfig(config) + + return &RegistryScan{ + Client: client, + }, nil +} + +func (r *RegistryScan) GetLabelDigest(ctx context.Context, imageInfo RegistryInfo) (RegistryInfo, error) { + out, err := r.Client.DescribeImages(ctx, &ecr.DescribeImagesInput{ + RegistryId: &imageInfo.RegistryID, + RepositoryName: &imageInfo.Name, + ImageIds: []types.ImageIdentifier{ + { + ImageTag: &imageInfo.Tag, + }, + }, + }) + if err != nil { + return RegistryInfo{}, err + } + if len(out.ImageDetails) == 0 { + return RegistryInfo{}, fmt.Errorf("no image found for image %s", imageInfo) + } + + // copy input and update tag from label to digest + digestInfo := imageInfo + digestInfo.Tag = *out.ImageDetails[0].ImageDigest + + return digestInfo, nil +} + +func (r *RegistryScan) WaitForScanFindings(ctx context.Context, digestInfo RegistryInfo) error { + waiter := ecr.NewImageScanCompleteWaiter(r.Client) + + // wait between attempts for between 3 and 15 secs (exponential backoff) + // wait for a maximum of 3 minutes + minAttemptDelay := 3 * time.Second + maxAttemptDelay := 15 * time.Second + maxTotalDelay := 3 * time.Minute + + return waiter.Wait(ctx, &ecr.DescribeImageScanFindingsInput{ + RegistryId: &digestInfo.RegistryID, + RepositoryName: &digestInfo.Name, + ImageId: &types.ImageIdentifier{ + ImageDigest: &digestInfo.Tag, + }, + }, maxTotalDelay, func(opts *ecr.ImageScanCompleteWaiterOptions) { + opts.LogWaitAttempts = true + opts.MinDelay = minAttemptDelay + opts.MaxDelay = maxAttemptDelay + }) +} + +func (r *RegistryScan) GetScanFindings(ctx context.Context, digestInfo RegistryInfo) (*ecr.DescribeImageScanFindingsOutput, error) { + pg := ecr.NewDescribeImageScanFindingsPaginator(r.Client, &ecr.DescribeImageScanFindingsInput{ + RegistryId: &digestInfo.RegistryID, + RepositoryName: &digestInfo.Name, + ImageId: &types.ImageIdentifier{ + ImageDigest: &digestInfo.Tag, + }, + }) + + var out *ecr.DescribeImageScanFindingsOutput + + for pg.HasMorePages() { + pg, err := pg.NextPage(ctx) + if err != nil { + return nil, err + } + + if out == nil { + out = pg + } else if out.ImageScanFindings != nil { + findings := out.ImageScanFindings + if findings == nil { + findings = &types.ImageScanFindings{} + out.ImageScanFindings = findings + } + + // build the entire set in memory 🤞 + findings.Findings = append(findings.Findings, pg.ImageScanFindings.Findings...) + findings.EnhancedFindings = append(findings.EnhancedFindings, pg.ImageScanFindings.EnhancedFindings...) + } + } + + return out, nil +} diff --git a/src/registry/ecr_test.go b/src/registry/ecr_test.go new file mode 100644 index 0000000..ea5dfa2 --- /dev/null +++ b/src/registry/ecr_test.go @@ -0,0 +1,55 @@ +package registry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryInfoFromURLSucceeds(t *testing.T) { + cases := []struct { + test string + url string + expected RegistryInfo + }{ + { + test: "Url with label", + url: "123456789012.dkr.ecr.us-west-2.amazonaws.com/test-repo:latest", + expected: RegistryInfo{ + RegistryID: "123456789012", + Region: "us-west-2", + Name: "test-repo", + Tag: "latest", + }, + }, + { + test: "Url without label", + url: "123456789012.dkr.ecr.us-west-2.amazonaws.com/test-repo", + expected: RegistryInfo{ + RegistryID: "123456789012", + Region: "us-west-2", + Name: "test-repo", + Tag: "", + }, + }, + } + + for _, c := range cases { + t.Run(c.test, func(t *testing.T) { + info, err := RegistryInfoFromURL(c.url) + require.NoError(t, err) + assert.Equal(t, c.expected, info) + }) + } +} + +func TestRegistryInfoFromURLFails(t *testing.T) { + url := "123456789012.dkr.ecr.us-west-2.amazonaws.com" + + info, err := RegistryInfoFromURL(url) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid registry URL") + + assert.Equal(t, RegistryInfo{}, info) +} diff --git a/src/report/annotation.go b/src/report/annotation.go new file mode 100644 index 0000000..62ce8fa --- /dev/null +++ b/src/report/annotation.go @@ -0,0 +1,83 @@ +package report + +import ( + "bytes" + _ "embed" + "fmt" + "html/template" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/cultureamp/ecrscanresults/registry" + "github.com/justincampbell/timeago" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +//go:embed annotation.gohtml +var annotationTemplateSource string + +type AnnotationContext struct { + Image registry.RegistryInfo + ImageLabel string + ScanFindings types.ImageScanFindings + CriticalSeverityThreshold int32 + HighSeverityThreshold int32 +} + +func (c AnnotationContext) Render() ([]byte, error) { + t, err := template. + New("annotation"). + Funcs(template.FuncMap{ + "titleCase": func(s string) string { + c := cases.Title(language.English) + return c.String(s) + }, + "lowerCase": strings.ToLower, + "findingAttribute": findingAttributeValue, + "nbsp": func(input string) any { + if len(input) > 0 { + return input + } else { + return template.HTML(` `) + } + }, + "timeAgo": func(tm *time.Time) string { + if tm == nil { + return "" + } + + return timeago.FromTime(*tm) + }, + "string": func(input any) (string, error) { + if strg, ok := input.(fmt.Stringer); ok { + return strg.String(), nil + } + + return fmt.Sprintf("%s", input), nil + }, + }). + Parse(annotationTemplateSource) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + err = t.Execute(&buf, c) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func findingAttributeValue(name string, finding types.ImageScanFinding) string { + for _, a := range finding.Attributes { + if aws.ToString(a.Key) == name { + return aws.ToString(a.Value) + } + } + return "" +} diff --git a/src/report/annotation.gohtml b/src/report/annotation.gohtml new file mode 100644 index 0000000..7cf483e --- /dev/null +++ b/src/report/annotation.gohtml @@ -0,0 +1,63 @@ +{{/* + +Expects an instance of AnnotationContext as its context. + +This template renders _MARKDOWN_, even though it's mostly HTML. This is why +there is no indentation: indented output can be rendered differently. + +*/}} +{{ $criticalThreshold := .CriticalSeverityThreshold }} +{{ $highThreshold := .HighSeverityThreshold }} +{{ if .ImageLabel }} +

Vulnerability summary for "{{ .ImageLabel }}"

+

{{ .Image.Name }}:{{ .Image.Tag }}

+{{ else }} +

Vulnerability summary for "{{ .Image.Name }}:{{ .Image.Tag }}"

+{{ end }} +{{ if .ScanFindings.FindingSeverityCounts }} +
+{{ range $key, $value := .ScanFindings.FindingSeverityCounts }} +{{ $exceedsThreshold := (or + (and (eq $key "CRITICAL") (gt $value $criticalThreshold)) + (and (eq $key "HIGH") (gt $value $highThreshold)) +) }} +
+
{{ $key | lowerCase | titleCase }}
+

{{ $value }}

+
+
+{{ end }} +
+{{ else }} +

No vulnerabilities reported.

+{{ end }} +{{ if .ScanFindings.Findings }} +
+Vulnerability details +
+ + + + + + + + +{{ range $f := .ScanFindings.Findings }} +{{ $vector := $f | findingAttribute "CVSS2_VECTOR"}} + + + + + + + +{{ end }} +
CVESeverityEffectsCVSS scoreVector
{{ if $f.Uri }}{{ $f.Name }}{{ else }}{{ $f.Name }}{{ end }}{{ $f.Severity | string | lowerCase | titleCase }}{{ $f | findingAttribute "package_name" | nbsp }} {{ $f | findingAttribute "package_version" | nbsp }}{{ $f | findingAttribute "CVSS2_SCORE" | nbsp}}{{ if $vector }}{{ $vector }}{{ else }} {{ end }}
+
+
+{{ end }} +

+scan completed: {{ .ScanFindings.ImageScanCompletedAt | timeAgo }} | +source updated: {{ .ScanFindings.VulnerabilitySourceUpdatedAt | timeAgo }} +

diff --git a/src/report/annotation_test.go b/src/report/annotation_test.go new file mode 100644 index 0000000..4b019a3 --- /dev/null +++ b/src/report/annotation_test.go @@ -0,0 +1,106 @@ +package report_test + +import ( + "fmt" + "testing" + + "github.com/MarvinJWendt/testza" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/cultureamp/ecrscanresults/registry" + "github.com/cultureamp/ecrscanresults/report" +) + +func TestReports(t *testing.T) { + cases := []struct { + name string + data report.AnnotationContext + }{ + { + name: "no vulnerabilities", + data: report.AnnotationContext{ + Image: registry.RegistryInfo{ + RegistryID: "0123456789", + Region: "us-west-2", + Name: "test-repo", + Tag: "digest-value", + }, + ImageLabel: "", + ScanFindings: types.ImageScanFindings{}, + CriticalSeverityThreshold: 0, + HighSeverityThreshold: 0, + }, + }, + { + name: "image label", + data: report.AnnotationContext{ + Image: registry.RegistryInfo{ + RegistryID: "0123456789", + Region: "us-west-2", + Name: "test-repo", + Tag: "digest-value", + }, + ImageLabel: "label of image", + ScanFindings: types.ImageScanFindings{}, + CriticalSeverityThreshold: 0, + HighSeverityThreshold: 0, + }, + }, + { + name: "findings included", + data: report.AnnotationContext{ + Image: registry.RegistryInfo{ + RegistryID: "0123456789", + Region: "us-west-2", + Name: "test-repo", + Tag: "digest-value", + }, + ImageLabel: "label of image", + ScanFindings: types.ImageScanFindings{ + FindingSeverityCounts: map[string]int32{ + "HIGH": 1, + }, + Findings: []types.ImageScanFinding{ + { + Name: aws.String("CVE-2019-5188"), + Description: aws.String("A code execution vulnerability exists in the directory rehashing functionality of E2fsprogs e2fsck 1.45.4. A specially crafted ext4 directory can cause an out-of-bounds write on the stack, resulting in code execution. An attacker can corrupt a partition to trigger this vulnerability."), + Uri: aws.String("http://people.ubuntu.com/~ubuntu-security/cve/CVE-2019-5188"), + Severity: "HIGH", + Attributes: []types.Attribute{ + { + Key: aws.String("package_version"), + Value: aws.String("1.44.1-1ubuntu1.1"), + }, + { + Key: aws.String("package_name"), + Value: aws.String("e2fsprogs"), + }, + { + Key: aws.String("CVSS2_VECTOR"), + Value: aws.String("AV:L/AC:L/Au:N/C:P/I:P/A:P"), + }, + { + Key: aws.String("CVSS2_SCORE"), + Value: aws.String("4.6"), + }, + }, + }, + }, + }, + CriticalSeverityThreshold: 0, + HighSeverityThreshold: 0, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fmt.Println(c.name, t.Name()) + result, err := c.data.Render() + + testza.AssertNoError(t, err) + err = testza.SnapshotCreateOrValidate(t, t.Name(), string(result)) + testza.AssertNoError(t, err) + }) + } +} diff --git a/src/report/testdata/snapshots/TestReports/findings_included.testza b/src/report/testdata/snapshots/TestReports/findings_included.testza new file mode 100755 index 0000000..05a83df --- /dev/null +++ b/src/report/testdata/snapshots/TestReports/findings_included.testza @@ -0,0 +1 @@ +(string) (len=899) "\n\n\n\n

Vulnerability summary for \"label of image\"

\n

test-repo:digest-value

\n\n\n
\n\n\n
\n
High
\n

1

\n
\n
\n\n
\n\n\n
\nVulnerability details\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CVESeverityEffectsCVSS scoreVector
CVE-2019-5188Highe2fsprogs 1.44.1-1ubuntu1.14.6AV:L/AC:L/Au:N/C:P/I:P/A:P
\n
\n
\n\n

\nscan completed: |\nsource updated: \n

\n" diff --git a/src/report/testdata/snapshots/TestReports/image_label.testza b/src/report/testdata/snapshots/TestReports/image_label.testza new file mode 100755 index 0000000..cc43a1e --- /dev/null +++ b/src/report/testdata/snapshots/TestReports/image_label.testza @@ -0,0 +1 @@ +(string) (len=288) "\n\n\n\n

Vulnerability summary for \"label of image\"

\n

test-repo:digest-value

\n\n\n

No vulnerabilities reported.

\n\n\n

\nscan completed: |\nsource updated: \n

\n" diff --git a/src/report/testdata/snapshots/TestReports/no_vulnerabilities.testza b/src/report/testdata/snapshots/TestReports/no_vulnerabilities.testza new file mode 100755 index 0000000..c49c013 --- /dev/null +++ b/src/report/testdata/snapshots/TestReports/no_vulnerabilities.testza @@ -0,0 +1 @@ +(string) (len=240) "\n\n\n\n

Vulnerability summary for \"test-repo:digest-value\"

\n\n\n

No vulnerabilities reported.

\n\n\n

\nscan completed: |\nsource updated: \n

\n" diff --git a/src/runtimeerrors/error.go b/src/runtimeerrors/error.go new file mode 100644 index 0000000..ef1afad --- /dev/null +++ b/src/runtimeerrors/error.go @@ -0,0 +1,32 @@ +package runtimeerrors + +import "errors" + +// NonFatalError is an error that will not cause the plugin to fail. +type NonFatalError struct { + Message string + Wrapped error +} + +func (e NonFatalError) Error() string { + m := e.Message + if e.Wrapped != nil { + m += ": " + e.Wrapped.Error() + } + return m +} + +func (e NonFatalError) Unwrap() error { + return e.Wrapped +} + +func NonFatal(message string, err error) NonFatalError { + return NonFatalError{ + Message: message, + Wrapped: err, + } +} + +func IsFatal(err error) bool { + return !errors.As(err, &NonFatalError{}) +} diff --git a/src/runtimeerrors/error_test.go b/src/runtimeerrors/error_test.go new file mode 100644 index 0000000..8a181e9 --- /dev/null +++ b/src/runtimeerrors/error_test.go @@ -0,0 +1,50 @@ +package runtimeerrors_test + +import ( + "fmt" + "testing" + + "github.com/cultureamp/ecrscanresults/runtimeerrors" + "github.com/stretchr/testify/assert" +) + +func TestIIsFatal(t *testing.T) { + cases := []struct { + test string + result bool + err error + }{ + { + test: "true when nil", + result: true, + err: nil, + }, + { + test: "true on normal error", + result: true, + err: assert.AnError, + }, + { + test: "false on nonfatal error", + result: false, + err: runtimeerrors.NonFatalError{}, + }, + { + test: "false on wrapped nonfatal error", + result: false, + err: fmt.Errorf("wrapped %w", runtimeerrors.NonFatal("end of the line", nil)), + }, + { + test: "false on nonfatal error in chain", + result: false, + err: fmt.Errorf("wrapped %w", runtimeerrors.NonFatal("wrapped further", assert.AnError)), + }, + } + + for _, c := range cases { + t.Run(c.test, func(t *testing.T) { + result := runtimeerrors.IsFatal(c.err) + assert.Equal(t, c.result, result) + }) + } +} diff --git a/tests/download.bats b/tests/download.bats new file mode 100755 index 0000000..38e140a --- /dev/null +++ b/tests/download.bats @@ -0,0 +1,48 @@ +#!/usr/bin/env bats + +load '/usr/local/lib/bats/load.bash' + +load '../lib/download.bash' + +# +# Tests for top-level docker bootstrap command. The rest of the plugin runs in Go. +# + +# Uncomment the following line to debug stub failures +# export [stub_command]_STUB_DEBUG=/dev/tty +#export DOCKER_STUB_DEBUG=/dev/tty + +setup() { + export BUILDKITE_PLUGIN_ECR_SCAN_RESULTS_BUILDKITE_PLUGIN_TEST_MODE=true +} + +teardown() { + unset BUILDKITE_PLUGIN_ECR_SCAN_RESULTS_BUILDKITE_PLUGIN_TEST_MODE + rm ./ecr-scan-results-buildkite-plugin || true +} + +create_script() { +cat > "$1" << EOM +set -euo pipefail + +echo "executing $1:\$@" + +EOM +} + +@test "Downloads and runs the command for the current architecture" { + + function downloader() { + echo "$@"; + create_script $2 + } + export -f downloader + + run download_binary_and_run + + unset downloader + + assert_success + assert_line --regexp "https://github.com/cultureamp/ecr-scan-results-buildkite-plugin/releases/latest/download/ecr-scan-results-buildkite-plugin_linux_amd64 ecr-scan-results-buildkite-plugin" + assert_line --regexp "executing ecr-scan-results-buildkite-plugin" +}