From 4bd3ea84da1ca34e2e292077c7465eda12af960b Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 6 Mar 2026 07:48:44 -0300 Subject: [PATCH 1/6] feat: first implementation Signed-off-by: Gustavo Carvalho --- .github/workflows/build-and-upload.yml | 28 + .github/workflows/push.yml | 13 + .github/workflows/release.yml | 13 + .github/workflows/test.yml | 15 + .gitignore | 1 + Makefile | 34 + go.mod | 57 ++ go.sum | 363 ++++++++++ main.go | 894 +++++++++++++++++++++++++ main_test.go | 555 +++++++++++++++ 10 files changed, 1973 insertions(+) create mode 100644 .github/workflows/build-and-upload.yml create mode 100644 .github/workflows/push.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml new file mode 100644 index 0000000..25b8b35 --- /dev/null +++ b/.github/workflows/build-and-upload.yml @@ -0,0 +1,28 @@ +name: Build and Upload Artifacts + +on: + workflow_call: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + # 'latest', 'nightly', or a semver + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install gooci cli + run: go install github.com/compliance-framework/gooci@latest + - name: Authenticate gooci cli + run: gooci login ghcr.io --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} + - name: gooci Upload Version + run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{github.ref_name}} + - name: gooci Upload Latest + if: "!github.event.release.prerelease" + run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..e6447a2 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,13 @@ +name: Push + +on: + pull_request: + push: + branches: + - '*' + +jobs: + test: + permissions: + contents: read + uses: ./.github/workflows/test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a50955 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +name: New Release + +on: + push: + tags: + - '*' + +jobs: + release: + permissions: + packages: write + contents: write + uses: ./.github/workflows/build-and-upload.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..84d5222 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +name: Go Test + +on: + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8388a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +policies diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..14d8665 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI catalog characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +# Check if OPA CLI is installed +OPA := $(shell command -v opa 2> /dev/null) +ifeq ($(OPA),) +$(error "opa CLI not found. Please install it: https://www.openpolicyagent.org/docs/latest/cli/") +endif + +##@ Help +help: ## Display this concise help, ie only the porcelain target + @awk 'BEGIN {FS = ":.*##"; printf "\033[1mUsage\033[0m\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +help-all: ## Display all help items, ie including plumbing targets + @awk 'BEGIN {FS = ":.*#"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?#/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^#@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + + + + +# Bundle the policies into a tarball for OCI registry +clean: # Cleanup build artifacts + @rm -rf dist/* + +build: clean ## Build the policy bundle + @mkdir -p dist/ + @go build -o dist/plugin main.go \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..55b5b3a --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module github.com/compliance-framework/plugin-cloud-custodian + +go 1.25.7 + +require ( + github.com/compliance-framework/agent v0.2.1 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.7.0 + github.com/mitchellh/mapstructure v1.5.0 +) + +require ( + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/agnivade/levenshtein v1.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/compliance-framework/api v0.4.4 // indirect + github.com/defenseunicorns/go-oscal v0.6.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/open-policy-agent/opa v1.0.0 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.57.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2f7c61a --- /dev/null +++ b/go.sum @@ -0,0 +1,363 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= +github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/compliance-framework/agent v0.2.1 h1:I2cvHRdkBiIXeud7STptpg0+pzHBSUMiFxIuI4EzdGc= +github.com/compliance-framework/agent v0.2.1/go.mod h1:fpUMZejzNNfwadGnrN8HpAAyka+UANx8LVhiLZeoPhg= +github.com/compliance-framework/api v0.4.4 h1:qY6Az+CBfx9cku/tzmrPX2d0qRaAfAXnQVopDIYwlQs= +github.com/compliance-framework/api v0.4.4/go.mod h1:UjL+VppIb0jmFbViiQSKkhUfY8X9I29faML7gl0fD1M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/defenseunicorns/go-oscal v0.6.3 h1:3j5aBobVX+Fy2GEIRCeg9MhsAgCKceOagVEDQPMuzZc= +github.com/defenseunicorns/go-oscal v0.6.3/go.mod h1:m55Ny/RTh4xWuxVSOD/poCZs9V9GOjNtjT0NujoxI6I= +github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= +github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v2.0.8+incompatible h1:ivUb1cGomAB101ZM1T0nOiWz9pSrTMoa9+EiY7igmkM= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/open-policy-agent/opa v1.0.0 h1:fZsEwxg1knpPvUn0YDJuJZBcbVg4G3zKpWa3+CnYK+I= +github.com/open-policy-agent/opa v1.0.0/go.mod h1:+JyoH12I0+zqyC1iX7a2tmoQlipwAEGvOhVJMhmy+rM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.57.0 h1:Ro/rKjwdq9mZn1K5QPctzh+MA4Lp0BuYk5ZZEVhoNcY= +github.com/prometheus/common v0.57.0/go.mod h1:7uRPFSUTbfZWsJ7MHY56sqt7hLQu3bxXHDnNhl8E9qI= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= +github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= +github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= +github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I= +gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d279992 --- /dev/null +++ b/main.go @@ -0,0 +1,894 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "maps" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" + "github.com/mitchellh/mapstructure" + "gopkg.in/yaml.v3" +) + +const ( + defaultCheckTimeoutSeconds = 300 + schemaVersionV1 = "v1" + sourceCloudCustodian = "cloud-custodian" +) + +var lookPath = exec.LookPath + +// PluginConfig receives string-only config from the agent gRPC interface. +type PluginConfig struct { + PoliciesYAML string `mapstructure:"policies_yaml"` + PoliciesPath string `mapstructure:"policies_path"` + CustodianBinary string `mapstructure:"custodian_binary"` + PolicyLabels string `mapstructure:"policy_labels"` + CheckTimeoutSeconds string `mapstructure:"check_timeout_seconds"` +} + +// ParsedConfig stores normalized and validated values for runtime use. +type ParsedConfig struct { + PoliciesYAML string + PoliciesPath string + CustodianBinary string + PolicyLabels map[string]string + CheckTimeout time.Duration +} + +func (c *PluginConfig) Parse() (*ParsedConfig, error) { + inlineYAML := strings.TrimSpace(c.PoliciesYAML) + policiesPath := strings.TrimSpace(c.PoliciesPath) + + if inlineYAML == "" && policiesPath == "" { + return nil, errors.New("either policies_yaml or policies_path is required") + } + + policyLabels := map[string]string{} + if strings.TrimSpace(c.PolicyLabels) != "" { + if err := json.Unmarshal([]byte(c.PolicyLabels), &policyLabels); err != nil { + return nil, fmt.Errorf("could not parse policy_labels: %w", err) + } + } + + checkTimeoutSeconds := defaultCheckTimeoutSeconds + if strings.TrimSpace(c.CheckTimeoutSeconds) != "" { + parsedTimeout, err := strconv.Atoi(c.CheckTimeoutSeconds) + if err != nil { + return nil, fmt.Errorf("check_timeout_seconds must be a positive integer: %w", err) + } + if parsedTimeout <= 0 { + return nil, errors.New("check_timeout_seconds must be greater than 0") + } + checkTimeoutSeconds = parsedTimeout + } + + binary := strings.TrimSpace(c.CustodianBinary) + if binary == "" { + binary = "custodian" + } + + resolvedBinary, err := lookPath(binary) + if err != nil { + return nil, fmt.Errorf("could not resolve custodian binary %q: %w", binary, err) + } + + return &ParsedConfig{ + PoliciesYAML: inlineYAML, + PoliciesPath: policiesPath, + CustodianBinary: resolvedBinary, + PolicyLabels: policyLabels, + CheckTimeout: time.Duration(checkTimeoutSeconds) * time.Second, + }, nil +} + +// CustodianCheck represents a single Cloud Custodian policy entry used as one check iteration. +type CustodianCheck struct { + Index int + Name string + Resource string + Provider string + RawPolicy map[string]interface{} + ParseErrors []string +} + +// CustodianExecutionRequest contains execution-time settings for one check run. +type CustodianExecutionRequest struct { + BinaryPath string + Check CustodianCheck + Timeout time.Duration + OutputDir string +} + +// CustodianExecutionResult captures runtime output and artifacts from one check run. +type CustodianExecutionResult struct { + StartedAt time.Time + EndedAt time.Time + ExitCode int + Stdout string + Stderr string + Error string + Errors []string + Err error + Resources []interface{} + ArtifactPath string + ResourcesPath string +} + +// CustodianExecutor runs one Cloud Custodian check and captures execution artifacts. +type CustodianExecutor interface { + Execute(ctx context.Context, req CustodianExecutionRequest) CustodianExecutionResult +} + +// CommandCustodianExecutor executes the custodian CLI. +type CommandCustodianExecutor struct { + Logger hclog.Logger +} + +func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExecutionRequest) CustodianExecutionResult { + result := CustodianExecutionResult{ + StartedAt: time.Now().UTC(), + ExitCode: -1, + Resources: []interface{}{}, + Errors: []string{}, + ArtifactPath: req.OutputDir, + } + + if err := os.MkdirAll(req.OutputDir, 0o755); err != nil { + result.Err = fmt.Errorf("failed to create output directory: %w", err) + result.Error = result.Err.Error() + result.Errors = []string{result.Error} + result.EndedAt = time.Now().UTC() + return result + } + + policyDocument := map[string]interface{}{ + "policies": []map[string]interface{}{req.Check.RawPolicy}, + } + policyContent, err := yaml.Marshal(policyDocument) + if err != nil { + result.Err = fmt.Errorf("failed to marshal single policy document: %w", err) + result.Error = result.Err.Error() + result.Errors = []string{result.Error} + result.EndedAt = time.Now().UTC() + return result + } + + policyPath := filepath.Join(req.OutputDir, "policy.yaml") + if err := os.WriteFile(policyPath, policyContent, 0o600); err != nil { + result.Err = fmt.Errorf("failed to write single policy file: %w", err) + result.Error = result.Err.Error() + result.Errors = []string{result.Error} + result.EndedAt = time.Now().UTC() + return result + } + + runCtx, cancel := context.WithTimeout(ctx, req.Timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, req.BinaryPath, "run", "--dryrun", "-s", req.OutputDir, policyPath) + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err = cmd.Run() + result.Stdout = stdoutBuf.String() + result.Stderr = stderrBuf.String() + if cmd.ProcessState != nil { + result.ExitCode = cmd.ProcessState.ExitCode() + } + + resourcesPath, resources, resourcesErr := readResourcesArtifact(req.OutputDir) + result.ResourcesPath = resourcesPath + if resources != nil { + result.Resources = resources + } + + if err != nil { + result.Err = fmt.Errorf("custodian execution failed: %w", err) + result.Errors = append(result.Errors, result.Err.Error()) + } + if runCtx.Err() != nil { + result.Err = errors.Join(result.Err, runCtx.Err()) + result.Errors = append(result.Errors, runCtx.Err().Error()) + } + if resourcesErr != nil { + result.Err = errors.Join(result.Err, resourcesErr) + result.Errors = append(result.Errors, resourcesErr.Error()) + } + + if result.Err != nil { + result.Error = strings.Join(result.Errors, "; ") + } + + result.EndedAt = time.Now().UTC() + return result +} + +func readResourcesArtifact(outputDir string) (string, []interface{}, error) { + resourcesPath, err := findResourcesJSON(outputDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", []interface{}{}, nil + } + return "", nil, fmt.Errorf("failed to locate resources.json: %w", err) + } + + content, err := os.ReadFile(resourcesPath) + if err != nil { + return resourcesPath, nil, fmt.Errorf("failed to read resources.json: %w", err) + } + + if len(content) == 0 { + return resourcesPath, []interface{}{}, nil + } + + resources := make([]interface{}, 0) + if err := json.Unmarshal(content, &resources); err != nil { + return resourcesPath, nil, fmt.Errorf("failed to parse resources.json: %w", err) + } + + return resourcesPath, resources, nil +} + +func findResourcesJSON(outputDir string) (string, error) { + found := "" + err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if d.Name() == "resources.json" { + found = path + return io.EOF + } + return nil + }) + + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + if found == "" { + return "", fs.ErrNotExist + } + return found, nil +} + +// StandardizedCheckPayload is the per-check OPA input contract. +type StandardizedCheckPayload struct { + SchemaVersion string `json:"schema_version"` + Source string `json:"source"` + Check StandardizedCheckInfo `json:"check"` + Execution StandardizedExecution `json:"execution"` + Result StandardizedCheckResult `json:"result"` + RawPolicy map[string]interface{} `json:"raw_policy"` +} + +type StandardizedCheckInfo struct { + Name string `json:"name"` + Resource string `json:"resource"` + Provider string `json:"provider"` + Index int `json:"index"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type StandardizedExecution struct { + Status string `json:"status"` + DryRun bool `json:"dry_run"` + ExitCode int `json:"exit_code"` + StartedAt string `json:"started_at"` + EndedAt string `json:"ended_at"` + DurationMS int64 `json:"duration_ms"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Error string `json:"error,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type StandardizedCheckResult struct { + MatchedResourceCount int `json:"matched_resource_count"` + Resources []interface{} `json:"resources"` + ArtifactPath string `json:"artifact_path,omitempty"` + ResourcesPath string `json:"resources_path,omitempty"` +} + +func buildCheckPayload(check CustodianCheck, execution CustodianExecutionResult) *StandardizedCheckPayload { + status := "success" + if execution.Error != "" { + status = "error" + } + + durationMS := int64(execution.EndedAt.Sub(execution.StartedAt) / time.Millisecond) + if durationMS < 0 { + durationMS = 0 + } + + metadata := map[string]interface{}{} + for k, v := range check.RawPolicy { + if k == "name" || k == "resource" { + continue + } + metadata[k] = v + } + + return &StandardizedCheckPayload{ + SchemaVersion: schemaVersionV1, + Source: sourceCloudCustodian, + Check: StandardizedCheckInfo{ + Name: check.Name, + Resource: check.Resource, + Provider: check.Provider, + Index: check.Index, + Metadata: metadata, + }, + Execution: StandardizedExecution{ + Status: status, + DryRun: true, + ExitCode: execution.ExitCode, + StartedAt: execution.StartedAt.UTC().Format(time.RFC3339Nano), + EndedAt: execution.EndedAt.UTC().Format(time.RFC3339Nano), + DurationMS: durationMS, + Stdout: execution.Stdout, + Stderr: execution.Stderr, + Error: execution.Error, + Errors: append([]string{}, execution.Errors...), + }, + Result: StandardizedCheckResult{ + MatchedResourceCount: len(execution.Resources), + Resources: execution.Resources, + ArtifactPath: execution.ArtifactPath, + ResourcesPath: execution.ResourcesPath, + }, + RawPolicy: check.RawPolicy, + } +} + +func parseCustodianChecks(policyYAML []byte) ([]CustodianCheck, error) { + decoded := map[string]interface{}{} + if err := yaml.Unmarshal(policyYAML, &decoded); err != nil { + return nil, fmt.Errorf("failed to parse cloud custodian policies yaml: %w", err) + } + + rawPolicies, ok := decoded["policies"] + if !ok { + return nil, errors.New("policy document must contain top-level policies array") + } + + policies, ok := rawPolicies.([]interface{}) + if !ok { + return nil, errors.New("top-level policies must be an array") + } + if len(policies) == 0 { + return nil, errors.New("top-level policies array must not be empty") + } + + checks := make([]CustodianCheck, 0, len(policies)) + for idx, raw := range policies { + normalized := normalizeYAMLValue(raw) + policyMap, mapOK := normalized.(map[string]interface{}) + if !mapOK { + checks = append(checks, CustodianCheck{ + Index: idx, + Name: fmt.Sprintf("policy-%d", idx+1), + Resource: "unknown", + Provider: "unknown", + RawPolicy: map[string]interface{}{"value": normalized}, + ParseErrors: []string{fmt.Sprintf("policy entry at index %d is not an object", idx)}, + }) + continue + } + + name := strings.TrimSpace(asString(policyMap["name"])) + resource := strings.TrimSpace(asString(policyMap["resource"])) + parseIssues := make([]string, 0) + if name == "" { + name = fmt.Sprintf("policy-%d", idx+1) + parseIssues = append(parseIssues, fmt.Sprintf("policy entry at index %d missing required name", idx)) + } + if resource == "" { + resource = "unknown" + parseIssues = append(parseIssues, fmt.Sprintf("policy %q missing required resource", name)) + } + + checks = append(checks, CustodianCheck{ + Index: idx, + Name: name, + Resource: resource, + Provider: extractProvider(resource), + RawPolicy: policyMap, + ParseErrors: parseIssues, + }) + } + + return checks, nil +} + +func normalizeYAMLValue(in interface{}) interface{} { + switch value := in.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(value)) + for k, v := range value { + out[k] = normalizeYAMLValue(v) + } + return out + case map[interface{}]interface{}: + out := make(map[string]interface{}, len(value)) + for k, v := range value { + out[fmt.Sprint(k)] = normalizeYAMLValue(v) + } + return out + case []interface{}: + out := make([]interface{}, len(value)) + for i, v := range value { + out[i] = normalizeYAMLValue(v) + } + return out + default: + return value + } +} + +func asString(value interface{}) string { + stringValue, ok := value.(string) + if !ok { + return "" + } + return stringValue +} + +func extractProvider(resource string) string { + resource = strings.TrimSpace(resource) + if resource == "" { + return "unknown" + } + + parts := strings.SplitN(resource, ".", 2) + if len(parts) < 2 { + return "unknown" + } + provider := strings.TrimSpace(parts[0]) + if provider == "" { + return "unknown" + } + return provider +} + +func resolvePoliciesYAML(ctx context.Context, inlineYAML string, policiesPath string) ([]byte, error) { + if strings.TrimSpace(inlineYAML) != "" { + return []byte(inlineYAML), nil + } + + pathValue := strings.TrimSpace(policiesPath) + if pathValue == "" { + return nil, errors.New("policies path is required when policies_yaml is empty") + } + + parsedURL, err := url.Parse(pathValue) + if err != nil { + return nil, fmt.Errorf("invalid policies_path: %w", err) + } + + switch parsedURL.Scheme { + case "": + content, err := os.ReadFile(pathValue) + if err != nil { + return nil, fmt.Errorf("failed to read local policies file: %w", err) + } + return content, nil + case "file": + if parsedURL.Path == "" { + return nil, errors.New("file:// policies_path must include a file path") + } + content, err := os.ReadFile(parsedURL.Path) + if err != nil { + return nil, fmt.Errorf("failed to read file:// policies file: %w", err) + } + return content, nil + case "http", "https": + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pathValue, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for policies_path: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch policies_path: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status code %d while fetching policies_path", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read policies_path response body: %w", err) + } + return content, nil + default: + return nil, fmt.Errorf("unsupported policies_path scheme: %s", parsedURL.Scheme) + } +} + +// PolicyEvaluator wraps OPA policy execution so eval loop behavior can be tested with mocks. +type PolicyEvaluator interface { + Generate( + ctx context.Context, + policyPath string, + labels map[string]string, + subjects []*proto.Subject, + components []*proto.Component, + inventory []*proto.InventoryItem, + actors []*proto.OriginActor, + activities []*proto.Activity, + data interface{}, + ) ([]*proto.Evidence, error) +} + +type DefaultPolicyEvaluator struct { + Logger hclog.Logger +} + +func (e *DefaultPolicyEvaluator) Generate( + ctx context.Context, + policyPath string, + labels map[string]string, + subjects []*proto.Subject, + components []*proto.Component, + inventory []*proto.InventoryItem, + actors []*proto.OriginActor, + activities []*proto.Activity, + data interface{}, +) ([]*proto.Evidence, error) { + processor := policyManager.NewPolicyProcessor( + e.Logger, + labels, + subjects, + components, + inventory, + actors, + activities, + ) + return processor.GenerateResults(ctx, policyPath, data) +} + +type CloudCustodianPlugin struct { + Logger hclog.Logger + + config *PluginConfig + parsedConfig *ParsedConfig + checks []CustodianCheck + + executor CustodianExecutor + evaluator PolicyEvaluator +} + +func (p *CloudCustodianPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { + p.Logger.Info("Configuring Cloud Custodian Plugin") + + config := &PluginConfig{} + if err := mapstructure.Decode(req.Config, config); err != nil { + p.Logger.Error("Error decoding config", "error", err) + return nil, err + } + + parsed, err := config.Parse() + if err != nil { + p.Logger.Error("Error parsing config", "error", err) + return nil, err + } + + resolvedPolicies, err := resolvePoliciesYAML(context.Background(), parsed.PoliciesYAML, parsed.PoliciesPath) + if err != nil { + p.Logger.Error("Error loading cloud custodian policies", "error", err) + return nil, err + } + + checks, err := parseCustodianChecks(resolvedPolicies) + if err != nil { + p.Logger.Error("Error parsing cloud custodian policies", "error", err) + return nil, err + } + + parsed.PoliciesYAML = string(resolvedPolicies) + p.config = config + p.parsedConfig = parsed + p.checks = checks + + if p.executor == nil { + p.executor = &CommandCustodianExecutor{Logger: p.Logger.Named("custodian-executor")} + } + if p.evaluator == nil { + p.evaluator = &DefaultPolicyEvaluator{Logger: p.Logger.Named("policy-evaluator")} + } + + p.Logger.Info("Cloud Custodian Plugin configured", "checks", len(checks)) + return &proto.ConfigureResponse{}, nil +} + +func (p *CloudCustodianPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { + ctx := context.Background() + + if p.parsedConfig == nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, errors.New("plugin not configured") + } + if len(p.checks) == 0 { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, errors.New("no cloud custodian checks configured") + } + if len(req.GetPolicyPaths()) == 0 { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, errors.New("no policy paths provided") + } + + executionRoot, err := os.MkdirTemp("", "ccf-cloud-custodian-*") + if err != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("failed to create execution workspace: %w", err) + } + defer os.RemoveAll(executionRoot) + + allEvidences := make([]*proto.Evidence, 0) + var accumulatedErrors error + successfulPolicyRuns := 0 + + for _, check := range p.checks { + execution := CustodianExecutionResult{} + if len(check.ParseErrors) > 0 { + execution = newCheckErrorExecution(check.ParseErrors) + } else { + checkDir := filepath.Join(executionRoot, fmt.Sprintf("%03d-%s", check.Index+1, sanitizeIdentifier(check.Name))) + execution = p.executor.Execute(ctx, CustodianExecutionRequest{ + BinaryPath: p.parsedConfig.CustodianBinary, + Check: check, + Timeout: p.parsedConfig.CheckTimeout, + OutputDir: checkDir, + }) + } + + payload := buildCheckPayload(check, execution) + evidences, evalErr, successfulRuns := p.evaluateCheckPolicies(ctx, payload, req.GetPolicyPaths()) + allEvidences = append(allEvidences, evidences...) + successfulPolicyRuns += successfulRuns + if evalErr != nil { + accumulatedErrors = errors.Join(accumulatedErrors, evalErr) + } + } + + if len(allEvidences) > 0 { + if err := apiHelper.CreateEvidence(ctx, allEvidences); err != nil { + p.Logger.Error("Error creating evidence", "error", err) + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err + } + } + + if successfulPolicyRuns == 0 && len(allEvidences) == 0 { + if accumulatedErrors == nil { + accumulatedErrors = errors.New("policy evaluation failed for all checks") + } + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, accumulatedErrors + } + + if accumulatedErrors != nil { + p.Logger.Warn("Completed with non-fatal policy evaluation errors", "error", accumulatedErrors) + } + + return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil +} + +func (p *CloudCustodianPlugin) evaluateCheckPolicies( + ctx context.Context, + payload *StandardizedCheckPayload, + policyPaths []string, +) ([]*proto.Evidence, error, int) { + labels := map[string]string{} + maps.Copy(labels, p.parsedConfig.PolicyLabels) + labels["provider"] = sourceCloudCustodian + labels["type"] = "check" + labels["check_name"] = payload.Check.Name + labels["check_resource"] = payload.Check.Resource + labels["check_provider"] = payload.Check.Provider + labels["check_status"] = payload.Execution.Status + + checkID := fmt.Sprintf("cloud-custodian-check/%s-%d", sanitizeIdentifier(payload.Check.Name), payload.Check.Index+1) + providerID := fmt.Sprintf("cloud-provider/%s", sanitizeIdentifier(payload.Check.Provider)) + + actors := []*proto.OriginActor{ + { + Title: "The Continuous Compliance Framework", + Type: "assessment-platform", + Links: []*proto.Link{ + { + Href: "https://compliance-framework.github.io/docs/", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework"), + }, + }, + }, + { + Title: "Continuous Compliance Framework - Cloud Custodian Plugin", + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/compliance-framework/plugin-cloud-custodian", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework Cloud Custodian Plugin"), + }, + }, + }, + } + + components := []*proto.Component{ + { + Identifier: "cloud-custodian/runtime", + Type: "tool", + Title: "Cloud Custodian Runtime", + Description: "Cloud Custodian CLI runtime used to execute provider policies in dry-run mode.", + Purpose: "To execute policy checks without mutating cloud resources.", + }, + { + Identifier: providerID, + Type: "service", + Title: fmt.Sprintf("Cloud Provider: %s", payload.Check.Provider), + Description: "Cloud service endpoint evaluated by the Cloud Custodian check.", + Purpose: "To provide resource inventory and configuration data for compliance checks.", + }, + } + + inventoryLinks := []*proto.Link{} + if payload.Result.ArtifactPath != "" { + inventoryLinks = append(inventoryLinks, &proto.Link{Href: payload.Result.ArtifactPath, Text: policyManager.Pointer("Custodian Artifact Directory")}) + } + if payload.Result.ResourcesPath != "" { + inventoryLinks = append(inventoryLinks, &proto.Link{Href: payload.Result.ResourcesPath, Text: policyManager.Pointer("Custodian resources.json")}) + } + + inventory := []*proto.InventoryItem{ + { + Identifier: checkID, + Type: "cloud-custodian-check", + Title: fmt.Sprintf("Cloud Custodian Check %s", payload.Check.Name), + Links: inventoryLinks, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + {Identifier: "cloud-custodian/runtime"}, + {Identifier: providerID}, + }, + }, + } + + subjects := []*proto.Subject{ + {Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, Identifier: checkID}, + {Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, Identifier: providerID}, + } + + activities := []*proto.Activity{ + { + Title: "Execute Cloud Custodian Check", + Steps: []*proto.Step{ + {Title: "Load Policy", Description: "Load one Cloud Custodian policy entry from the configured policy document."}, + {Title: "Run Dry-Run Check", Description: "Execute Cloud Custodian using --dryrun and capture generated artifacts."}, + {Title: "Build Standardized Payload", Description: "Convert execution output and matched resources into standardized OPA input."}, + }, + }, + { + Title: "Evaluate OPA Policy Bundles", + Steps: []*proto.Step{ + {Title: "Evaluate Check Payload", Description: "Run policy bundles against the standardized Cloud Custodian check payload."}, + }, + }, + } + + allEvidences := make([]*proto.Evidence, 0) + var accumulatedErrors error + successfulRuns := 0 + + for _, policyPath := range policyPaths { + evidences, err := p.evaluator.Generate( + ctx, + policyPath, + labels, + subjects, + components, + inventory, + actors, + activities, + payload, + ) + allEvidences = append(allEvidences, evidences...) + if err != nil { + accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s failed for check %s: %w", policyPath, payload.Check.Name, err)) + continue + } + successfulRuns++ + } + + return allEvidences, accumulatedErrors, successfulRuns +} + +func newCheckErrorExecution(messages []string) CustodianExecutionResult { + now := time.Now().UTC() + normalized := make([]string, 0, len(messages)) + for _, message := range messages { + if strings.TrimSpace(message) == "" { + continue + } + normalized = append(normalized, message) + } + joined := strings.Join(normalized, "; ") + if joined == "" { + joined = "check failed" + normalized = []string{joined} + } + err := errors.New(joined) + return CustodianExecutionResult{ + StartedAt: now, + EndedAt: now, + ExitCode: -1, + Error: joined, + Errors: normalized, + Err: err, + Resources: []interface{}{}, + } +} + +func sanitizeIdentifier(in string) string { + trimmed := strings.TrimSpace(strings.ToLower(in)) + if trimmed == "" { + return "unknown" + } + + builder := strings.Builder{} + prevDash := false + for _, r := range trimmed { + isAlphaNum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if isAlphaNum { + builder.WriteRune(r) + prevDash = false + continue + } + if !prevDash { + builder.WriteRune('-') + prevDash = true + } + } + + out := strings.Trim(builder.String(), "-") + if out == "" { + return "unknown" + } + return out +} + +func main() { + logger := hclog.New(&hclog.LoggerOptions{ + Level: hclog.Trace, + JSONFormat: true, + }) + + plugin := &CloudCustodianPlugin{Logger: logger} + + logger.Info("Starting Cloud Custodian Plugin") + goplugin.Serve(&goplugin.ServeConfig{ + HandshakeConfig: runner.HandshakeConfig, + Plugins: map[string]goplugin.Plugin{ + "runner": &runner.RunnerGRPCPlugin{Impl: plugin}, + }, + GRPCServer: goplugin.DefaultGRPCServer, + }) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f107b19 --- /dev/null +++ b/main_test.go @@ -0,0 +1,555 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" +) + +func stubLookPath(t *testing.T, fn func(string) (string, error)) { + t.Helper() + original := lookPath + lookPath = fn + t.Cleanup(func() { + lookPath = original + }) +} + +func TestPluginConfigParse(t *testing.T) { + stubLookPath(t, func(binary string) (string, error) { + return "/usr/local/bin/" + binary, nil + }) + + t.Run("inline only", func(t *testing.T) { + cfg := &PluginConfig{ + PoliciesYAML: `policies: + - name: s3-check + resource: aws.s3`, + } + + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.PoliciesYAML == "" { + t.Fatalf("expected inline yaml to be preserved") + } + if parsed.CheckTimeout != 300*time.Second { + t.Fatalf("expected default timeout 300s, got %s", parsed.CheckTimeout) + } + if parsed.CustodianBinary != "/usr/local/bin/custodian" { + t.Fatalf("unexpected resolved binary: %s", parsed.CustodianBinary) + } + }) + + t.Run("path only", func(t *testing.T) { + cfg := &PluginConfig{PoliciesPath: "/tmp/policies.yaml", CustodianBinary: "custom-custodian", CheckTimeoutSeconds: "45"} + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.PoliciesPath != "/tmp/policies.yaml" { + t.Fatalf("unexpected policies path: %s", parsed.PoliciesPath) + } + if parsed.CheckTimeout != 45*time.Second { + t.Fatalf("expected timeout 45s, got %s", parsed.CheckTimeout) + } + if parsed.CustodianBinary != "/usr/local/bin/custom-custodian" { + t.Fatalf("unexpected resolved binary: %s", parsed.CustodianBinary) + } + }) + + t.Run("reject empty sources", func(t *testing.T) { + _, err := (&PluginConfig{}).Parse() + if err == nil { + t.Fatalf("expected error for missing policies source") + } + }) + + t.Run("reject invalid labels json", func(t *testing.T) { + _, err := (&PluginConfig{PoliciesYAML: "x", PolicyLabels: "{"}).Parse() + if err == nil { + t.Fatalf("expected error for invalid policy_labels json") + } + }) + + t.Run("reject invalid timeout", func(t *testing.T) { + _, err := (&PluginConfig{PoliciesYAML: "x", CheckTimeoutSeconds: "abc"}).Parse() + if err == nil { + t.Fatalf("expected error for invalid timeout") + } + }) +} + +func TestResolvePoliciesYAML(t *testing.T) { + t.Run("inline preferred over path", func(t *testing.T) { + content, err := resolvePoliciesYAML(context.Background(), "policies: []", "https://example.invalid/policies.yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "policies: []" { + t.Fatalf("unexpected content: %s", string(content)) + } + }) + + t.Run("local file", func(t *testing.T) { + f := filepath.Join(t.TempDir(), "policies.yaml") + if err := os.WriteFile(f, []byte("policies: []"), 0o600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + content, err := resolvePoliciesYAML(context.Background(), "", f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "policies: []" { + t.Fatalf("unexpected content: %s", string(content)) + } + }) + + t.Run("http success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("policies:\n - name: test\n resource: aws.s3")) + })) + defer srv.Close() + + content, err := resolvePoliciesYAML(context.Background(), "", srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(content), "name: test") { + t.Fatalf("expected fetched yaml content") + } + }) + + t.Run("http non-2xx", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + + _, err := resolvePoliciesYAML(context.Background(), "", srv.URL) + if err == nil { + t.Fatalf("expected error for non-2xx response") + } + }) + + t.Run("unsupported scheme", func(t *testing.T) { + _, err := resolvePoliciesYAML(context.Background(), "", "s3://bucket/policies.yaml") + if err == nil { + t.Fatalf("expected error for unsupported scheme") + } + }) +} + +func TestParseCustodianChecks(t *testing.T) { + t.Run("valid policies", func(t *testing.T) { + checks, err := parseCustodianChecks([]byte(`policies: + - name: s3-public + resource: aws.s3 + mode: + type: periodic + - name: vm-policy + resource: azure.vm`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + if checks[0].Provider != "aws" { + t.Fatalf("expected provider aws, got %s", checks[0].Provider) + } + if len(checks[0].ParseErrors) != 0 { + t.Fatalf("expected no parse errors, got %v", checks[0].ParseErrors) + } + }) + + t.Run("invalid entries become check errors", func(t *testing.T) { + checks, err := parseCustodianChecks([]byte(`policies: + - "not-an-object" + - name: missing-resource`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + if len(checks[0].ParseErrors) == 0 { + t.Fatalf("expected parse error for first check") + } + if len(checks[1].ParseErrors) == 0 { + t.Fatalf("expected parse error for second check") + } + }) + + t.Run("missing top-level policies fails", func(t *testing.T) { + _, err := parseCustodianChecks([]byte(`foo: bar`)) + if err == nil { + t.Fatalf("expected error when top-level policies missing") + } + }) +} + +func writeExecutableScript(t *testing.T, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell script helper is not supported on windows") + } + + binary := filepath.Join(t.TempDir(), "custodian") + if err := os.WriteFile(binary, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write script: %v", err) + } + return binary +} + +func TestCommandCustodianExecutor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executor tests use POSIX shell scripts") + } + + t.Run("passes required args and loads resources", func(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + t.Setenv("ARGS_FILE", argsFile) + + script := `#!/bin/sh +set -eu +echo "$@" > "$ARGS_FILE" +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-s" ]; then + out="$2" + shift 2 + continue + fi + shift + done +mkdir -p "$out/test-policy" +printf '[{"id":"abc"}]' > "$out/test-policy/resources.json" +` + binary := writeExecutableScript(t, script) + + executor := &CommandCustodianExecutor{Logger: hclog.NewNullLogger()} + outDir := filepath.Join(t.TempDir(), "out") + result := executor.Execute(context.Background(), CustodianExecutionRequest{ + BinaryPath: binary, + Check: CustodianCheck{ + Name: "test-policy", + Resource: "aws.s3", + Provider: "aws", + RawPolicy: map[string]interface{}{"name": "test-policy", "resource": "aws.s3"}, + }, + Timeout: 5 * time.Second, + OutputDir: outDir, + }) + + if result.Err != nil { + t.Fatalf("expected successful execution, got error: %v", result.Err) + } + if result.ExitCode != 0 { + t.Fatalf("expected exit code 0, got %d", result.ExitCode) + } + if len(result.Resources) != 1 { + t.Fatalf("expected one resource, got %d", len(result.Resources)) + } + if result.ResourcesPath == "" { + t.Fatalf("expected resources path to be set") + } + + argsContent, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("failed to read args capture file: %v", err) + } + argsStr := string(argsContent) + if !strings.Contains(argsStr, "run --dryrun -s") { + t.Fatalf("expected dry-run args, got: %s", argsStr) + } + if !strings.Contains(argsStr, "policy.yaml") { + t.Fatalf("expected policy.yaml argument, got: %s", argsStr) + } + }) + + t.Run("timeout cancellation", func(t *testing.T) { + script := `#!/bin/sh +sleep 2 +` + binary := writeExecutableScript(t, script) + executor := &CommandCustodianExecutor{Logger: hclog.NewNullLogger()} + + result := executor.Execute(context.Background(), CustodianExecutionRequest{ + BinaryPath: binary, + Check: CustodianCheck{ + Name: "slow-check", + Resource: "aws.ec2", + Provider: "aws", + RawPolicy: map[string]interface{}{"name": "slow-check", "resource": "aws.ec2"}, + }, + Timeout: 100 * time.Millisecond, + OutputDir: filepath.Join(t.TempDir(), "out"), + }) + + if result.Err == nil { + t.Fatalf("expected timeout error") + } + if !strings.Contains(result.Error, "deadline exceeded") { + t.Fatalf("expected deadline exceeded in error, got: %s", result.Error) + } + if len(result.Errors) == 0 { + t.Fatalf("expected structured execution errors") + } + }) +} + +func TestBuildCheckPayload(t *testing.T) { + now := time.Now().UTC() + check := CustodianCheck{ + Index: 0, + Name: "s3-check", + Resource: "aws.s3", + Provider: "aws", + RawPolicy: map[string]interface{}{ + "name": "s3-check", + "resource": "aws.s3", + "mode": map[string]interface{}{ + "type": "periodic", + }, + }, + } + + success := buildCheckPayload(check, CustodianExecutionResult{ + StartedAt: now, + EndedAt: now.Add(2 * time.Second), + ExitCode: 0, + Stdout: "ok", + Resources: []interface{}{map[string]interface{}{"id": "a"}}, + ArtifactPath: "/tmp/out", + ResourcesPath: "/tmp/out/s3/resources.json", + }) + + if success.SchemaVersion != "v1" { + t.Fatalf("expected schema version v1") + } + if success.Execution.Status != "success" { + t.Fatalf("expected success status") + } + if success.Result.MatchedResourceCount != 1 { + t.Fatalf("expected matched count 1, got %d", success.Result.MatchedResourceCount) + } + if success.Check.Metadata["mode"] == nil { + t.Fatalf("expected metadata to include non-name/resource fields") + } + + failure := buildCheckPayload(check, newCheckErrorExecution([]string{"parse failure"})) + if failure.Execution.Status != "error" { + t.Fatalf("expected error status") + } + if failure.Result.MatchedResourceCount != 0 { + t.Fatalf("expected matched count 0 for failed payload") + } + if len(failure.Execution.Errors) != 1 || failure.Execution.Errors[0] != "parse failure" { + t.Fatalf("expected structured execution errors in payload, got %v", failure.Execution.Errors) + } +} + +type fakeExecutor struct { + calls []CustodianExecutionRequest + results map[string]CustodianExecutionResult +} + +func (f *fakeExecutor) Execute(ctx context.Context, req CustodianExecutionRequest) CustodianExecutionResult { + f.calls = append(f.calls, req) + if result, ok := f.results[req.Check.Name]; ok { + return result + } + now := time.Now().UTC() + return CustodianExecutionResult{StartedAt: now, EndedAt: now, ExitCode: 0, Resources: []interface{}{}} +} + +type fakePolicyEvaluator struct { + calls []string + failChecks map[string]bool +} + +func (f *fakePolicyEvaluator) Generate( + ctx context.Context, + policyPath string, + labels map[string]string, + subjects []*proto.Subject, + components []*proto.Component, + inventory []*proto.InventoryItem, + actors []*proto.OriginActor, + activities []*proto.Activity, + data interface{}, +) ([]*proto.Evidence, error) { + payload, ok := data.(*StandardizedCheckPayload) + if !ok { + return nil, errors.New("unexpected payload type") + } + + f.calls = append(f.calls, fmt.Sprintf("%s|%s|%s", payload.Check.Name, policyPath, payload.Execution.Status)) + if f.failChecks[payload.Check.Name] { + return nil, errors.New("forced evaluator error") + } + + return []*proto.Evidence{{UUID: fmt.Sprintf("%s-%s", payload.Check.Name, policyPath), Labels: labels}}, nil +} + +type fakeAPIHelper struct { + calls int + evidence []*proto.Evidence + err error +} + +func (f *fakeAPIHelper) CreateEvidence(ctx context.Context, evidence []*proto.Evidence) error { + f.calls++ + f.evidence = append(f.evidence, evidence...) + return f.err +} + +func TestEvalLoopBehavior(t *testing.T) { + now := time.Now().UTC() + + t.Run("continues on check execution errors and submits evidence", func(t *testing.T) { + executor := &fakeExecutor{results: map[string]CustodianExecutionResult{ + "check-a": { + StartedAt: now, + EndedAt: now.Add(20 * time.Millisecond), + ExitCode: 0, + Resources: []interface{}{map[string]interface{}{"id": "1"}}, + }, + "check-b": { + StartedAt: now, + EndedAt: now.Add(10 * time.Millisecond), + ExitCode: 1, + Error: "execution failed", + Err: errors.New("execution failed"), + Resources: []interface{}{}, + }, + }} + + evaluator := &fakePolicyEvaluator{} + apiHelper := &fakeAPIHelper{} + + plugin := &CloudCustodianPlugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + PolicyLabels: map[string]string{"team": "platform"}, + CheckTimeout: 30 * time.Second, + }, + checks: []CustodianCheck{ + {Index: 0, Name: "check-a", Resource: "aws.s3", Provider: "aws", RawPolicy: map[string]interface{}{"name": "check-a", "resource": "aws.s3"}}, + {Index: 1, Name: "check-b", Resource: "aws.ec2", Provider: "aws", RawPolicy: map[string]interface{}{"name": "check-b", "resource": "aws.ec2"}}, + }, + executor: executor, + evaluator: evaluator, + } + + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a", "bundle-b"}}, apiHelper) + if err != nil { + t.Fatalf("unexpected eval error: %v", err) + } + if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Fatalf("expected success status, got %s", resp.GetStatus().String()) + } + if len(executor.calls) != 2 { + t.Fatalf("expected 2 executor calls, got %d", len(executor.calls)) + } + if len(evaluator.calls) != 4 { + t.Fatalf("expected 4 evaluator calls, got %d", len(evaluator.calls)) + } + if apiHelper.calls != 1 { + t.Fatalf("expected CreateEvidence once, got %d", apiHelper.calls) + } + if len(apiHelper.evidence) != 4 { + t.Fatalf("expected 4 evidences, got %d", len(apiHelper.evidence)) + } + + hasErrorStatusPayload := false + for _, call := range evaluator.calls { + if strings.Contains(call, "check-b|") && strings.HasSuffix(call, "|error") { + hasErrorStatusPayload = true + break + } + } + if !hasErrorStatusPayload { + t.Fatalf("expected check-b payload to carry error execution status") + } + }) + + t.Run("fails when all policy evaluations fail", func(t *testing.T) { + executor := &fakeExecutor{results: map[string]CustodianExecutionResult{ + "check-a": { + StartedAt: now, + EndedAt: now, + ExitCode: 0, + Resources: []interface{}{}, + }, + }} + + evaluator := &fakePolicyEvaluator{failChecks: map[string]bool{"check-a": true}} + apiHelper := &fakeAPIHelper{} + + plugin := &CloudCustodianPlugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + PolicyLabels: map[string]string{}, + CheckTimeout: 30 * time.Second, + }, + checks: []CustodianCheck{ + {Index: 0, Name: "check-a", Resource: "aws.s3", Provider: "aws", RawPolicy: map[string]interface{}{"name": "check-a", "resource": "aws.s3"}}, + }, + executor: executor, + evaluator: evaluator, + } + + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) + if err == nil { + t.Fatalf("expected eval failure when all policy evaluations fail") + } + if resp.GetStatus() != proto.ExecutionStatus_FAILURE { + t.Fatalf("expected failure status, got %s", resp.GetStatus().String()) + } + if apiHelper.calls != 0 { + t.Fatalf("expected no evidence submission, got %d calls", apiHelper.calls) + } + }) +} + +func TestConfigureLoadsChecks(t *testing.T) { + stubLookPath(t, func(binary string) (string, error) { + return "/usr/local/bin/" + binary, nil + }) + + plugin := &CloudCustodianPlugin{Logger: hclog.NewNullLogger()} + resp, err := plugin.Configure(&proto.ConfigureRequest{Config: map[string]string{ + "policies_yaml": `policies: + - name: s3-check + resource: aws.s3`, + "policy_labels": `{"environment":"dev"}`, + }}) + if err != nil { + t.Fatalf("unexpected configure error: %v", err) + } + if resp == nil { + t.Fatalf("expected configure response") + } + if len(plugin.checks) != 1 { + t.Fatalf("expected one parsed check, got %d", len(plugin.checks)) + } + if plugin.parsedConfig.PolicyLabels["environment"] != "dev" { + t.Fatalf("expected parsed policy label") + } +} From 72596281fc08992d626531bbe976c00d780df150 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 6 Mar 2026 12:40:20 -0300 Subject: [PATCH 2/6] fix: bugs here and there Signed-off-by: Gustavo Carvalho --- README.md | 3 + main.go | 220 ++++++++++++++++++++++++++++++++++++++++++++++----- main_test.go | 75 ++++++++++++++++++ 3 files changed, 279 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 39989b2..38ae297 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This plugin always enforces read-only Cloud Custodian execution: - `--dryrun` is always used. - Mutating actions are not applied. +- For AWS checks, the plugin runs with `--region all` to evaluate across all AWS regions by default. ## Configuration @@ -36,6 +37,8 @@ All plugin config fields are strings (agent gRPC `map` contract). | `custodian_binary` | No | Path/name of Cloud Custodian executable. Default: `custodian`. | | `check_timeout_seconds` | No | Per-check timeout in seconds. Default: `300`. | | `policy_labels` | No | JSON map of labels merged into generated evidence labels. | +| `debug_dump_payloads` | No | Boolean (`true`/`false`) toggle to write standardized check payload JSON files for troubleshooting. Default: `false`. | +| `debug_payload_output_dir` | No | Directory where debug payload JSON files are written. If set, debug dumping is auto-enabled. Default when enabled without explicit path: `debug-standardized-payloads`. | Validation rules: diff --git a/main.go b/main.go index d279992..acee9ca 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "time" @@ -37,20 +38,24 @@ var lookPath = exec.LookPath // PluginConfig receives string-only config from the agent gRPC interface. type PluginConfig struct { - PoliciesYAML string `mapstructure:"policies_yaml"` - PoliciesPath string `mapstructure:"policies_path"` - CustodianBinary string `mapstructure:"custodian_binary"` - PolicyLabels string `mapstructure:"policy_labels"` - CheckTimeoutSeconds string `mapstructure:"check_timeout_seconds"` + PoliciesYAML string `mapstructure:"policies_yaml"` + PoliciesPath string `mapstructure:"policies_path"` + CustodianBinary string `mapstructure:"custodian_binary"` + PolicyLabels string `mapstructure:"policy_labels"` + CheckTimeoutSeconds string `mapstructure:"check_timeout_seconds"` + DebugDumpPayloads string `mapstructure:"debug_dump_payloads"` + DebugPayloadOutputDir string `mapstructure:"debug_payload_output_dir"` } // ParsedConfig stores normalized and validated values for runtime use. type ParsedConfig struct { - PoliciesYAML string - PoliciesPath string - CustodianBinary string - PolicyLabels map[string]string - CheckTimeout time.Duration + PoliciesYAML string + PoliciesPath string + CustodianBinary string + PolicyLabels map[string]string + CheckTimeout time.Duration + DebugDumpPayloads bool + DebugPayloadOutputDir string } func (c *PluginConfig) Parse() (*ParsedConfig, error) { @@ -90,12 +95,31 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { return nil, fmt.Errorf("could not resolve custodian binary %q: %w", binary, err) } + debugDumpPayloads := false + if strings.TrimSpace(c.DebugDumpPayloads) != "" { + parsedDebug, err := strconv.ParseBool(c.DebugDumpPayloads) + if err != nil { + return nil, fmt.Errorf("debug_dump_payloads must be a boolean value: %w", err) + } + debugDumpPayloads = parsedDebug + } + + debugPayloadOutputDir := strings.TrimSpace(c.DebugPayloadOutputDir) + if debugPayloadOutputDir != "" { + debugDumpPayloads = true + } + if debugDumpPayloads && debugPayloadOutputDir == "" { + debugPayloadOutputDir = "debug-standardized-payloads" + } + return &ParsedConfig{ - PoliciesYAML: inlineYAML, - PoliciesPath: policiesPath, - CustodianBinary: resolvedBinary, - PolicyLabels: policyLabels, - CheckTimeout: time.Duration(checkTimeoutSeconds) * time.Second, + PoliciesYAML: inlineYAML, + PoliciesPath: policiesPath, + CustodianBinary: resolvedBinary, + PolicyLabels: policyLabels, + CheckTimeout: time.Duration(checkTimeoutSeconds) * time.Second, + DebugDumpPayloads: debugDumpPayloads, + DebugPayloadOutputDir: debugPayloadOutputDir, }, nil } @@ -143,6 +167,15 @@ type CommandCustodianExecutor struct { } func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExecutionRequest) CustodianExecutionResult { + e.Logger.Debug("Starting cloud custodian execution", + "check_name", req.Check.Name, + "check_index", req.Check.Index, + "resource", req.Check.Resource, + "provider", req.Check.Provider, + "binary", req.BinaryPath, + "timeout", req.Timeout.String(), + "output_dir", req.OutputDir, + ) result := CustodianExecutionResult{ StartedAt: time.Now().UTC(), ExitCode: -1, @@ -155,9 +188,11 @@ func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExe result.Err = fmt.Errorf("failed to create output directory: %w", err) result.Error = result.Err.Error() result.Errors = []string{result.Error} + e.Logger.Error("Failed creating output directory for check", "check_name", req.Check.Name, "error", result.Error) result.EndedAt = time.Now().UTC() return result } + e.Logger.Trace("Created output directory for check", "check_name", req.Check.Name, "output_dir", req.OutputDir) policyDocument := map[string]interface{}{ "policies": []map[string]interface{}{req.Check.RawPolicy}, @@ -167,6 +202,7 @@ func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExe result.Err = fmt.Errorf("failed to marshal single policy document: %w", err) result.Error = result.Err.Error() result.Errors = []string{result.Error} + e.Logger.Error("Failed marshaling single policy yaml for check", "check_name", req.Check.Name, "error", result.Error) result.EndedAt = time.Now().UTC() return result } @@ -176,14 +212,26 @@ func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExe result.Err = fmt.Errorf("failed to write single policy file: %w", err) result.Error = result.Err.Error() result.Errors = []string{result.Error} + e.Logger.Error("Failed writing single policy file for check", "check_name", req.Check.Name, "policy_path", policyPath, "error", result.Error) result.EndedAt = time.Now().UTC() return result } + e.Logger.Trace("Wrote single policy file", "check_name", req.Check.Name, "policy_path", policyPath) runCtx, cancel := context.WithTimeout(ctx, req.Timeout) defer cancel() - cmd := exec.CommandContext(runCtx, req.BinaryPath, "run", "--dryrun", "-s", req.OutputDir, policyPath) + args := []string{"run", "--dryrun", "-s", req.OutputDir, policyPath} + if strings.EqualFold(req.Check.Provider, "aws") { + // Ensure AWS policies evaluate across all regions by default. + args = append(args, "--region", "all") + } + cmd := exec.CommandContext(runCtx, req.BinaryPath, args...) + e.Logger.Debug("Executing custodian command", + "check_name", req.Check.Name, + "command", req.BinaryPath, + "args", args, + ) var stdoutBuf bytes.Buffer var stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf @@ -195,6 +243,12 @@ func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExe if cmd.ProcessState != nil { result.ExitCode = cmd.ProcessState.ExitCode() } + e.Logger.Debug("Custodian command finished", + "check_name", req.Check.Name, + "exit_code", result.ExitCode, + "stdout_len", len(result.Stdout), + "stderr_len", len(result.Stderr), + ) resourcesPath, resources, resourcesErr := readResourcesArtifact(req.OutputDir) result.ResourcesPath = resourcesPath @@ -217,6 +271,17 @@ func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExe if result.Err != nil { result.Error = strings.Join(result.Errors, "; ") + e.Logger.Warn("Custodian execution completed with errors", + "check_name", req.Check.Name, + "error_count", len(result.Errors), + "errors", result.Errors, + ) + } else { + e.Logger.Debug("Custodian execution completed successfully", + "check_name", req.Check.Name, + "resource_count", len(result.Resources), + "resources_path", result.ResourcesPath, + ) } result.EndedAt = time.Now().UTC() @@ -559,6 +624,7 @@ func (e *DefaultPolicyEvaluator) Generate( activities []*proto.Activity, data interface{}, ) ([]*proto.Evidence, error) { + e.Logger.Debug("Evaluating OPA policy against check payload", "policy_path", policyPath, "labels", labels) processor := policyManager.NewPolicyProcessor( e.Logger, labels, @@ -568,7 +634,13 @@ func (e *DefaultPolicyEvaluator) Generate( actors, activities, ) - return processor.GenerateResults(ctx, policyPath, data) + evidence, err := processor.GenerateResults(ctx, policyPath, data) + if err != nil { + e.Logger.Warn("OPA policy evaluation failed", "policy_path", policyPath, "error", err) + return evidence, err + } + e.Logger.Debug("OPA policy evaluation succeeded", "policy_path", policyPath, "evidence_count", len(evidence)) + return evidence, nil } type CloudCustodianPlugin struct { @@ -583,7 +655,7 @@ type CloudCustodianPlugin struct { } func (p *CloudCustodianPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { - p.Logger.Info("Configuring Cloud Custodian Plugin") + p.Logger.Debug("Received raw plugin configuration", "config_keys", sortedKeys(req.Config)) config := &PluginConfig{} if err := mapstructure.Decode(req.Config, config); err != nil { @@ -596,24 +668,49 @@ func (p *CloudCustodianPlugin) Configure(req *proto.ConfigureRequest) (*proto.Co p.Logger.Error("Error parsing config", "error", err) return nil, err } + p.Logger.Debug("Parsed plugin configuration", + "has_inline_policies_yaml", strings.TrimSpace(parsed.PoliciesYAML) != "", + "policies_path", parsed.PoliciesPath, + "custodian_binary", parsed.CustodianBinary, + "check_timeout", parsed.CheckTimeout.String(), + "policy_labels", parsed.PolicyLabels, + "debug_dump_payloads", parsed.DebugDumpPayloads, + "debug_payload_output_dir", parsed.DebugPayloadOutputDir, + ) resolvedPolicies, err := resolvePoliciesYAML(context.Background(), parsed.PoliciesYAML, parsed.PoliciesPath) if err != nil { p.Logger.Error("Error loading cloud custodian policies", "error", err) return nil, err } + p.Logger.Debug("Resolved cloud custodian policy source", "policy_yaml_bytes", len(resolvedPolicies)) checks, err := parseCustodianChecks(resolvedPolicies) if err != nil { p.Logger.Error("Error parsing cloud custodian policies", "error", err) return nil, err } + parseErrorChecks := 0 + for _, check := range checks { + if len(check.ParseErrors) > 0 { + parseErrorChecks++ + p.Logger.Debug("Parsed check with non-fatal parse issues", "check_name", check.Name, "index", check.Index, "parse_errors", check.ParseErrors) + } + } parsed.PoliciesYAML = string(resolvedPolicies) p.config = config p.parsedConfig = parsed p.checks = checks + if parsed.DebugDumpPayloads { + if err := os.MkdirAll(parsed.DebugPayloadOutputDir, 0o755); err != nil { + p.Logger.Error("Failed creating debug payload output directory", "debug_payload_output_dir", parsed.DebugPayloadOutputDir, "error", err) + return nil, fmt.Errorf("failed creating debug payload output directory %q: %w", parsed.DebugPayloadOutputDir, err) + } + p.Logger.Debug("Debug payload dumping enabled", "debug_payload_output_dir", parsed.DebugPayloadOutputDir) + } + if p.executor == nil { p.executor = &CommandCustodianExecutor{Logger: p.Logger.Named("custodian-executor")} } @@ -621,7 +718,7 @@ func (p *CloudCustodianPlugin) Configure(req *proto.ConfigureRequest) (*proto.Co p.evaluator = &DefaultPolicyEvaluator{Logger: p.Logger.Named("policy-evaluator")} } - p.Logger.Info("Cloud Custodian Plugin configured", "checks", len(checks)) + p.Logger.Info("Cloud Custodian Plugin configured", "checks", len(checks), "checks_with_parse_errors", parseErrorChecks) return &proto.ConfigureResponse{}, nil } @@ -643,14 +740,17 @@ func (p *CloudCustodianPlugin) Eval(req *proto.EvalRequest, apiHelper runner.Api return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("failed to create execution workspace: %w", err) } defer os.RemoveAll(executionRoot) + p.Logger.Debug("Created temporary execution root", "execution_root", executionRoot) allEvidences := make([]*proto.Evidence, 0) var accumulatedErrors error successfulPolicyRuns := 0 for _, check := range p.checks { + p.Logger.Debug("Processing check", "check_name", check.Name, "check_index", check.Index, "resource", check.Resource, "provider", check.Provider) execution := CustodianExecutionResult{} if len(check.ParseErrors) > 0 { + p.Logger.Warn("Skipping custodian execution due to check parse issues", "check_name", check.Name, "parse_errors", check.ParseErrors) execution = newCheckErrorExecution(check.ParseErrors) } else { checkDir := filepath.Join(executionRoot, fmt.Sprintf("%03d-%s", check.Index+1, sanitizeIdentifier(check.Name))) @@ -663,19 +763,39 @@ func (p *CloudCustodianPlugin) Eval(req *proto.EvalRequest, apiHelper runner.Api } payload := buildCheckPayload(check, execution) + p.Logger.Debug("Built standardized check payload", + "check_name", payload.Check.Name, + "status", payload.Execution.Status, + "matched_resource_count", payload.Result.MatchedResourceCount, + "execution_error_count", len(payload.Execution.Errors), + ) + if p.parsedConfig.DebugDumpPayloads { + if err := p.dumpStandardizedPayload(payload); err != nil { + p.Logger.Warn("Failed writing debug standardized payload", "check_name", payload.Check.Name, "error", err) + } + } evidences, evalErr, successfulRuns := p.evaluateCheckPolicies(ctx, payload, req.GetPolicyPaths()) allEvidences = append(allEvidences, evidences...) successfulPolicyRuns += successfulRuns + p.Logger.Debug("Completed policy evaluations for check", + "check_name", payload.Check.Name, + "successful_policy_runs", successfulRuns, + "produced_evidence_count", len(evidences), + "had_eval_error", evalErr != nil, + ) if evalErr != nil { accumulatedErrors = errors.Join(accumulatedErrors, evalErr) } } if len(allEvidences) > 0 { + p.Logger.Debug("Submitting evidence batch via ApiHelper", "evidence_count", len(allEvidences)) if err := apiHelper.CreateEvidence(ctx, allEvidences); err != nil { p.Logger.Error("Error creating evidence", "error", err) return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err } + } else { + p.Logger.Warn("No evidence generated by current evaluation run") } if successfulPolicyRuns == 0 && len(allEvidences) == 0 { @@ -697,6 +817,11 @@ func (p *CloudCustodianPlugin) evaluateCheckPolicies( payload *StandardizedCheckPayload, policyPaths []string, ) ([]*proto.Evidence, error, int) { + p.Logger.Debug("Evaluating policy paths for check", + "check_name", payload.Check.Name, + "check_status", payload.Execution.Status, + "policy_paths_count", len(policyPaths), + ) labels := map[string]string{} maps.Copy(labels, p.parsedConfig.PolicyLabels) labels["provider"] = sourceCloudCustodian @@ -799,6 +924,7 @@ func (p *CloudCustodianPlugin) evaluateCheckPolicies( successfulRuns := 0 for _, policyPath := range policyPaths { + p.Logger.Trace("Running policy path for check", "check_name", payload.Check.Name, "policy_path", policyPath) evidences, err := p.evaluator.Generate( ctx, policyPath, @@ -812,15 +938,62 @@ func (p *CloudCustodianPlugin) evaluateCheckPolicies( ) allEvidences = append(allEvidences, evidences...) if err != nil { + p.Logger.Warn("Policy path evaluation failed for check", + "check_name", payload.Check.Name, + "policy_path", policyPath, + "error", err, + ) accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("policy %s failed for check %s: %w", policyPath, payload.Check.Name, err)) continue } + p.Logger.Trace("Policy path evaluation succeeded for check", + "check_name", payload.Check.Name, + "policy_path", policyPath, + "evidence_count", len(evidences), + ) successfulRuns++ } + p.Logger.Debug("Completed policy path loop for check", + "check_name", payload.Check.Name, + "successful_runs", successfulRuns, + "evidence_count", len(allEvidences), + "had_errors", accumulatedErrors != nil, + ) return allEvidences, accumulatedErrors, successfulRuns } +func (p *CloudCustodianPlugin) dumpStandardizedPayload(payload *StandardizedCheckPayload) error { + if payload == nil { + return errors.New("payload is nil") + } + if p.parsedConfig == nil || !p.parsedConfig.DebugDumpPayloads { + return nil + } + + content, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + fileName := fmt.Sprintf("%03d-%s-%d.json", + payload.Check.Index+1, + sanitizeIdentifier(payload.Check.Name), + time.Now().UTC().UnixNano(), + ) + outputPath := filepath.Join(p.parsedConfig.DebugPayloadOutputDir, fileName) + if err := os.WriteFile(outputPath, content, 0o600); err != nil { + return fmt.Errorf("write payload file %s: %w", outputPath, err) + } + + p.Logger.Debug("Wrote standardized payload debug file", + "check_name", payload.Check.Name, + "output_path", outputPath, + "bytes", len(content), + ) + return nil +} + func newCheckErrorExecution(messages []string) CustodianExecutionResult { now := time.Now().UTC() normalized := make([]string, 0, len(messages)) @@ -875,6 +1048,15 @@ func sanitizeIdentifier(in string) string { return out } +func sortedKeys(input map[string]string) []string { + keys := make([]string, 0, len(input)) + for k := range input { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + func main() { logger := hclog.New(&hclog.LoggerOptions{ Level: hclog.Trace, diff --git a/main_test.go b/main_test.go index f107b19..85f7722 100644 --- a/main_test.go +++ b/main_test.go @@ -90,6 +90,30 @@ func TestPluginConfigParse(t *testing.T) { t.Fatalf("expected error for invalid timeout") } }) + + t.Run("reject invalid debug boolean", func(t *testing.T) { + _, err := (&PluginConfig{PoliciesYAML: "x", DebugDumpPayloads: "not-bool"}).Parse() + if err == nil { + t.Fatalf("expected error for invalid debug_dump_payloads") + } + }) + + t.Run("enable debug dump when output dir is set", func(t *testing.T) { + cfg := &PluginConfig{ + PoliciesYAML: "policies: []", + DebugPayloadOutputDir: "/tmp/custom-debug-dir", + } + parsed, err := cfg.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !parsed.DebugDumpPayloads { + t.Fatalf("expected debug dump to auto-enable when output dir is provided") + } + if parsed.DebugPayloadOutputDir != "/tmp/custom-debug-dir" { + t.Fatalf("unexpected debug output dir: %s", parsed.DebugPayloadOutputDir) + } + }) } func TestResolvePoliciesYAML(t *testing.T) { @@ -280,6 +304,9 @@ printf '[{"id":"abc"}]' > "$out/test-policy/resources.json" if !strings.Contains(argsStr, "policy.yaml") { t.Fatalf("expected policy.yaml argument, got: %s", argsStr) } + if !strings.Contains(argsStr, "--region all") { + t.Fatalf("expected aws region fanout args, got: %s", argsStr) + } }) t.Run("timeout cancellation", func(t *testing.T) { @@ -553,3 +580,51 @@ func TestConfigureLoadsChecks(t *testing.T) { t.Fatalf("expected parsed policy label") } } + +func TestDumpStandardizedPayload(t *testing.T) { + plugin := &CloudCustodianPlugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + DebugDumpPayloads: true, + DebugPayloadOutputDir: t.TempDir(), + }, + } + + err := plugin.dumpStandardizedPayload(&StandardizedCheckPayload{ + SchemaVersion: "v1", + Source: "cloud-custodian", + Check: StandardizedCheckInfo{ + Name: "check-a", + Resource: "aws.s3", + Provider: "aws", + Index: 0, + }, + Execution: StandardizedExecution{ + Status: "success", + DryRun: true, + }, + Result: StandardizedCheckResult{ + MatchedResourceCount: 0, + Resources: []interface{}{}, + }, + RawPolicy: map[string]interface{}{"name": "check-a", "resource": "aws.s3"}, + }) + if err != nil { + t.Fatalf("unexpected dump error: %v", err) + } + + files, err := os.ReadDir(plugin.parsedConfig.DebugPayloadOutputDir) + if err != nil { + t.Fatalf("failed to read debug output dir: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected one dumped payload file, got %d", len(files)) + } + content, err := os.ReadFile(filepath.Join(plugin.parsedConfig.DebugPayloadOutputDir, files[0].Name())) + if err != nil { + t.Fatalf("failed to read dumped payload file: %v", err) + } + if !strings.Contains(string(content), "\"schema_version\": \"v1\"") { + t.Fatalf("dumped payload file content does not look like standardized payload json") + } +} From 1a342faddaa8a6218c513ede6c6627a209b835a7 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 6 Mar 2026 13:18:45 -0300 Subject: [PATCH 3/6] fix: copilot issues related to code Signed-off-by: Gustavo Carvalho --- go.mod | 2 +- main.go | 24 +++++++++++++++---- main_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 55b5b3a..80459bb 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/mitchellh/mapstructure v1.5.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -52,6 +53,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/main.go b/main.go index acee9ca..c9adcf9 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ const ( defaultCheckTimeoutSeconds = 300 schemaVersionV1 = "v1" sourceCloudCustodian = "cloud-custodian" + defaultRemotePolicyTimeout = 30 * time.Second ) var lookPath = exec.LookPath @@ -325,12 +326,12 @@ func findResourcesJSON(outputDir string) (string, error) { } if d.Name() == "resources.json" { found = path - return io.EOF + return filepath.SkipAll } return nil }) - if err != nil && !errors.Is(err, io.EOF) { + if err != nil && !errors.Is(err, filepath.SkipAll) { return "", err } if found == "" { @@ -548,6 +549,16 @@ func resolvePoliciesYAML(ctx context.Context, inlineYAML string, policiesPath st return nil, errors.New("policies path is required when policies_yaml is empty") } + // Treat values that do not look like URLs as local filesystem paths. + // This handles Windows paths like `C:\policies.yaml` correctly. + if !strings.Contains(pathValue, "://") { + content, err := os.ReadFile(pathValue) + if err != nil { + return nil, fmt.Errorf("failed to read local policies file: %w", err) + } + return content, nil + } + parsedURL, err := url.Parse(pathValue) if err != nil { return nil, fmt.Errorf("invalid policies_path: %w", err) @@ -574,7 +585,8 @@ func resolvePoliciesYAML(ctx context.Context, inlineYAML string, policiesPath st if err != nil { return nil, fmt.Errorf("failed to create request for policies_path: %w", err) } - resp, err := http.DefaultClient.Do(req) + httpClient := &http.Client{Timeout: defaultRemotePolicyTimeout} + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch policies_path: %w", err) } @@ -824,7 +836,11 @@ func (p *CloudCustodianPlugin) evaluateCheckPolicies( ) labels := map[string]string{} maps.Copy(labels, p.parsedConfig.PolicyLabels) - labels["provider"] = sourceCloudCustodian + labels["source"] = sourceCloudCustodian + labels["tool"] = sourceCloudCustodian + if _, exists := labels["provider"]; !exists { + labels["provider"] = payload.Check.Provider + } labels["type"] = "check" labels["check_name"] = payload.Check.Name labels["check_resource"] = payload.Check.Resource diff --git a/main_test.go b/main_test.go index 85f7722..e0290ee 100644 --- a/main_test.go +++ b/main_test.go @@ -176,6 +176,16 @@ func TestResolvePoliciesYAML(t *testing.T) { t.Fatalf("expected error for unsupported scheme") } }) + + t.Run("windows style path treated as local path", func(t *testing.T) { + _, err := resolvePoliciesYAML(context.Background(), "", `C:\policies.yaml`) + if err == nil { + t.Fatalf("expected local file read error for missing windows-style path") + } + if !strings.Contains(err.Error(), "failed to read local policies file") { + t.Fatalf("expected local file read path handling, got: %v", err) + } + }) } func TestParseCustodianChecks(t *testing.T) { @@ -408,6 +418,7 @@ func (f *fakeExecutor) Execute(ctx context.Context, req CustodianExecutionReques type fakePolicyEvaluator struct { calls []string failChecks map[string]bool + labelsSeen []map[string]string } func (f *fakePolicyEvaluator) Generate( @@ -427,6 +438,11 @@ func (f *fakePolicyEvaluator) Generate( } f.calls = append(f.calls, fmt.Sprintf("%s|%s|%s", payload.Check.Name, policyPath, payload.Execution.Status)) + copiedLabels := map[string]string{} + for k, v := range labels { + copiedLabels[k] = v + } + f.labelsSeen = append(f.labelsSeen, copiedLabels) if f.failChecks[payload.Check.Name] { return nil, errors.New("forced evaluator error") } @@ -553,6 +569,58 @@ func TestEvalLoopBehavior(t *testing.T) { t.Fatalf("expected no evidence submission, got %d calls", apiHelper.calls) } }) + + t.Run("preserves user provider label and adds source labels", func(t *testing.T) { + executor := &fakeExecutor{results: map[string]CustodianExecutionResult{ + "check-a": { + StartedAt: now, + EndedAt: now, + ExitCode: 0, + Resources: []interface{}{}, + }, + }} + + evaluator := &fakePolicyEvaluator{} + apiHelper := &fakeAPIHelper{} + + plugin := &CloudCustodianPlugin{ + Logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + PolicyLabels: map[string]string{"provider": "custom-provider", "team": "platform"}, + CheckTimeout: 30 * time.Second, + }, + checks: []CustodianCheck{ + {Index: 0, Name: "check-a", Resource: "aws.s3", Provider: "aws", RawPolicy: map[string]interface{}{"name": "check-a", "resource": "aws.s3"}}, + }, + executor: executor, + evaluator: evaluator, + } + + resp, err := plugin.Eval(&proto.EvalRequest{PolicyPaths: []string{"bundle-a"}}, apiHelper) + if err != nil { + t.Fatalf("unexpected eval error: %v", err) + } + if resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Fatalf("expected success status, got %s", resp.GetStatus().String()) + } + if len(evaluator.labelsSeen) == 0 { + t.Fatalf("expected evaluator to capture labels") + } + + labels := evaluator.labelsSeen[0] + if labels["provider"] != "custom-provider" { + t.Fatalf("expected provider label to be preserved, got: %s", labels["provider"]) + } + if labels["source"] != sourceCloudCustodian { + t.Fatalf("expected source label, got: %s", labels["source"]) + } + if labels["tool"] != sourceCloudCustodian { + t.Fatalf("expected tool label, got: %s", labels["tool"]) + } + if labels["check_provider"] != "aws" { + t.Fatalf("expected check_provider label to be aws, got: %s", labels["check_provider"]) + } + }) } func TestConfigureLoadsChecks(t *testing.T) { From baf7c2d689ff13ee53bf2c03d0653757b3951fab Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 6 Mar 2026 13:26:39 -0300 Subject: [PATCH 4/6] fix: copilot issues Signed-off-by: Gustavo Carvalho --- main.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index c9adcf9..295b16e 100644 --- a/main.go +++ b/main.go @@ -892,20 +892,13 @@ func (p *CloudCustodianPlugin) evaluateCheckPolicies( }, } - inventoryLinks := []*proto.Link{} - if payload.Result.ArtifactPath != "" { - inventoryLinks = append(inventoryLinks, &proto.Link{Href: payload.Result.ArtifactPath, Text: policyManager.Pointer("Custodian Artifact Directory")}) - } - if payload.Result.ResourcesPath != "" { - inventoryLinks = append(inventoryLinks, &proto.Link{Href: payload.Result.ResourcesPath, Text: policyManager.Pointer("Custodian resources.json")}) - } - inventory := []*proto.InventoryItem{ { Identifier: checkID, Type: "cloud-custodian-check", Title: fmt.Sprintf("Cloud Custodian Check %s", payload.Check.Name), - Links: inventoryLinks, + // Local temp artifact paths are intentionally not exposed in evidence links + // because they are ephemeral and not portable for downstream consumers. ImplementedComponents: []*proto.InventoryItemImplementedComponent{ {Identifier: "cloud-custodian/runtime"}, {Identifier: providerID}, From 8e4ecdb4f259551ce751ec5ccedbca99442c5d73 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 6 Mar 2026 13:49:07 -0300 Subject: [PATCH 5/6] feat: copilot ifxes Signed-off-by: Gustavo Carvalho --- main.go | 27 +++++++++++++++++++-------- main_test.go | 27 +++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/main.go b/main.go index 295b16e..8ef982a 100644 --- a/main.go +++ b/main.go @@ -29,10 +29,11 @@ import ( ) const ( - defaultCheckTimeoutSeconds = 300 - schemaVersionV1 = "v1" - sourceCloudCustodian = "cloud-custodian" - defaultRemotePolicyTimeout = 30 * time.Second + defaultCheckTimeoutSeconds = 300 + schemaVersionV1 = "v1" + sourceCloudCustodian = "cloud-custodian" + defaultRemotePolicyTimeout = 30 * time.Second + defaultMaxRemotePolicyBytes = 1 << 20 // 1 MiB ) var lookPath = exec.LookPath @@ -261,9 +262,13 @@ func (e *CommandCustodianExecutor) Execute(ctx context.Context, req CustodianExe result.Err = fmt.Errorf("custodian execution failed: %w", err) result.Errors = append(result.Errors, result.Err.Error()) } - if runCtx.Err() != nil { - result.Err = errors.Join(result.Err, runCtx.Err()) - result.Errors = append(result.Errors, runCtx.Err().Error()) + if runErr := runCtx.Err(); runErr != nil { + // Avoid duplicating context timeout/cancel errors when cmd.Run already + // returned an error that wraps the same context failure. + if err == nil || !errors.Is(err, runErr) { + result.Err = errors.Join(result.Err, runErr) + result.Errors = append(result.Errors, runErr.Error()) + } } if resourcesErr != nil { result.Err = errors.Join(result.Err, resourcesErr) @@ -595,11 +600,17 @@ func resolvePoliciesYAML(ctx context.Context, inlineYAML string, policiesPath st if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("unexpected status code %d while fetching policies_path", resp.StatusCode) } + if resp.ContentLength > defaultMaxRemotePolicyBytes { + return nil, fmt.Errorf("policies_path response too large: content-length=%d exceeds max=%d bytes", resp.ContentLength, defaultMaxRemotePolicyBytes) + } - content, err := io.ReadAll(resp.Body) + content, err := io.ReadAll(io.LimitReader(resp.Body, defaultMaxRemotePolicyBytes+1)) if err != nil { return nil, fmt.Errorf("failed to read policies_path response body: %w", err) } + if len(content) > defaultMaxRemotePolicyBytes { + return nil, fmt.Errorf("policies_path response too large: size=%d exceeds max=%d bytes", len(content), defaultMaxRemotePolicyBytes) + } return content, nil default: return nil, fmt.Errorf("unsupported policies_path scheme: %s", parsedURL.Scheme) diff --git a/main_test.go b/main_test.go index e0290ee..a02d244 100644 --- a/main_test.go +++ b/main_test.go @@ -170,6 +170,23 @@ func TestResolvePoliciesYAML(t *testing.T) { } }) + t.Run("http response too large", func(t *testing.T) { + oversized := strings.Repeat("a", defaultMaxRemotePolicyBytes+1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(oversized)) + })) + defer srv.Close() + + _, err := resolvePoliciesYAML(context.Background(), "", srv.URL) + if err == nil { + t.Fatalf("expected error for oversized response body") + } + if !strings.Contains(err.Error(), "too large") { + t.Fatalf("expected oversized body error, got: %v", err) + } + }) + t.Run("unsupported scheme", func(t *testing.T) { _, err := resolvePoliciesYAML(context.Background(), "", "s3://bucket/policies.yaml") if err == nil { @@ -344,8 +361,14 @@ sleep 2 if !strings.Contains(result.Error, "deadline exceeded") { t.Fatalf("expected deadline exceeded in error, got: %s", result.Error) } - if len(result.Errors) == 0 { - t.Fatalf("expected structured execution errors") + deadlineMentions := 0 + for _, msg := range result.Errors { + if strings.Contains(msg, "deadline exceeded") { + deadlineMentions++ + } + } + if deadlineMentions > 1 { + t.Fatalf("expected at most one deadline exceeded entry, got: %v", result.Errors) } }) } From eb8940cadb3c2f3de77500ea1c9efa468c620ce2 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 6 Mar 2026 14:08:04 -0300 Subject: [PATCH 6/6] fix: copilot issues Signed-off-by: Gustavo Carvalho --- main.go | 12 ++++++++++-- main_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 8ef982a..6c19d87 100644 --- a/main.go +++ b/main.go @@ -394,14 +394,22 @@ func buildCheckPayload(check CustodianCheck, execution CustodianExecutionResult) durationMS = 0 } - metadata := map[string]interface{}{} + var metadata map[string]interface{} for k, v := range check.RawPolicy { if k == "name" || k == "resource" { continue } + if metadata == nil { + metadata = map[string]interface{}{} + } metadata[k] = v } + var executionErrors []string + if len(execution.Errors) > 0 { + executionErrors = append([]string{}, execution.Errors...) + } + return &StandardizedCheckPayload{ SchemaVersion: schemaVersionV1, Source: sourceCloudCustodian, @@ -422,7 +430,7 @@ func buildCheckPayload(check CustodianCheck, execution CustodianExecutionResult) Stdout: execution.Stdout, Stderr: execution.Stderr, Error: execution.Error, - Errors: append([]string{}, execution.Errors...), + Errors: executionErrors, }, Result: StandardizedCheckResult{ MatchedResourceCount: len(execution.Resources), diff --git a/main_test.go b/main_test.go index a02d244..dc32453 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -422,6 +423,35 @@ func TestBuildCheckPayload(t *testing.T) { if len(failure.Execution.Errors) != 1 || failure.Execution.Errors[0] != "parse failure" { t.Fatalf("expected structured execution errors in payload, got %v", failure.Execution.Errors) } + + minimalCheck := CustodianCheck{ + Index: 1, + Name: "minimal-check", + Resource: "aws.s3", + Provider: "aws", + RawPolicy: map[string]interface{}{ + "name": "minimal-check", + "resource": "aws.s3", + }, + } + minimal := buildCheckPayload(minimalCheck, CustodianExecutionResult{ + StartedAt: now, + EndedAt: now, + ExitCode: 0, + Resources: []interface{}{}, + }) + + rawJSON, err := json.Marshal(minimal) + if err != nil { + t.Fatalf("failed to marshal minimal payload: %v", err) + } + jsonText := string(rawJSON) + if strings.Contains(jsonText, `"metadata":{}`) { + t.Fatalf("expected metadata to be omitted when empty, got: %s", jsonText) + } + if strings.Contains(jsonText, `"errors":[]`) { + t.Fatalf("expected execution.errors to be omitted when empty, got: %s", jsonText) + } } type fakeExecutor struct {