From 08a69a1fd6fb99860cf3e02fe30c2813f52ea7ec Mon Sep 17 00:00:00 2001 From: James Salt Date: Wed, 3 Jun 2026 14:30:13 +0100 Subject: [PATCH 1/2] BCH-1296: Initial commit of scaffolding and logic for ECR Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 20 ++ .gitignore | 1 + .goreleaser.yaml | 36 +++ Makefile | 18 ++ README.md | 83 +++++++ examples/agent-config.yaml | 61 +++++ go.mod | 72 ++++++ go.sum | 432 ++++++++++++++++++++++++++++++++++ internal/config.go | 44 ++++ internal/config_test.go | 97 ++++++++ internal/data.go | 380 ++++++++++++++++++++++++++++++ internal/data_test.go | 470 +++++++++++++++++++++++++++++++++++++ internal/eval.go | 261 ++++++++++++++++++++ internal/util.go | 15 ++ main.go | 188 +++++++++++++++ 15 files changed, 2178 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Makefile create mode 100644 README.md create mode 100644 examples/agent-config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config.go create mode 100644 internal/config_test.go create mode 100644 internal/data.go create mode 100644 internal/data_test.go create mode 100644 internal/eval.go create mode 100644 internal/util.go create mode 100644 main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9189a0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: go vet + run: go vet ./... + - name: go test + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..350e229 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + - go mod tidy + - go generate ./... + +builds: + - binary: plugin + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "arm64" }}arm64 + {{- else }}{{ .Arch }}{{ end }} + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..034fd38 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: help test clean build run + +##@ Help +help: ## Display this help + @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) + +test: ## Run unit tests + @go test ./... + +clean: ## Remove build artifacts + @rm -rf dist/* + +build: clean ## Build the plugin binary + @mkdir -p dist/ + @go build -o dist/plugin main.go + +run: build ## Run the agent with the built plugin + @../agent/dist/./concom agent --config ./.config/config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..af8f6e0 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# plugin-aws-ecr + +CCF compliance plugin for AWS Elastic Container Registry (ECR). Evaluates private ECR repositories and container image scan results against SOC2 TSC 2017 controls. + +## What it checks + +### CONFIG checks (per repository) + +| Check | Description | Controls | +|-------|-------------|---------| +| `ecr_require_scan_on_push` | `imageScanningConfiguration.scanOnPush` must be `true` | CC5.3, CC6.8, CC7.1 | +| `ecr_require_tag_immutability` | `imageTagImmutability` must be `IMMUTABLE` | CC6.8, CC8.1 | +| `ecr_require_encryption` | Encryption type must be in `approved_encryption_types` | CC5.2 | +| `ecr_require_lifecycle_policy` | Repository must have a lifecycle policy configured | CC6.5 | +| `ecr_deny_public_access` | Resource policy must not grant `Principal: "*"` with `Effect: Allow` | CC6.8, CC8.1 | +| `ecr_require_tags` | Repository must carry all `required_repository_tags` | CC6.1 | + +### CONFIG checks (per registry / account+region) + +| Check | Description | Controls | +|-------|-------------|---------| +| `ecr_require_registry_scanning` | Registry-level scan type must be in `approved_registry_scan_types` | CC5.2, CC5.3, CC7.1 | + +### DYNAMIC checks (per image digest, 90-day lookback) + +| Check | Description | Controls | +|-------|-------------|---------| +| `ecr_require_image_scan_complete` | Image scan `status` must be `COMPLETE` | CC3.2, CC5.2, CC7.1, CC8.1 | +| `ecr_require_no_critical_image_findings` | `CRITICAL` finding count must be 0 | CC6.8, CC7.1, CC8.1 | +| `ecr_require_no_high_image_findings` | `HIGH` finding count must be ≤ `max_high_finding_count` | CC6.8, CC7.1, CC8.1 | +| `ecr_require_scan_findings_retrievable` | Scan findings with severity data must be accessible | CC6.8, CC7.1 | + +## Required IAM actions + +```json +{ + "Effect": "Allow", + "Action": [ + "ecr:DescribeRepositories", + "ecr:GetLifecyclePolicy", + "ecr:GetRepositoryPolicy", + "ecr:ListTagsForResource", + "ecr:GetRegistryScanningConfiguration", + "ecr:DescribeImages", + "ecr:DescribeImageScanFindings" + ], + "Resource": "*" +} +``` + +## Configuration + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `regions` | `string` | Yes | Comma-separated AWS regions to scan (e.g. `"us-east-1,eu-west-1"`) | +| `accounts` | `string` | No | Comma-separated account IDs to filter on. If omitted, all repositories in the region are evaluated. | +| `policy_labels` | `string` (JSON) | No | Extra labels added to every Evidence record (e.g. `{"env":"prod"}`) | + +## Policy data (overrides `data.json` defaults) + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `approved_encryption_types` | `[]string` | `["KMS"]` | Allowed ECR encryption types | +| `approved_registry_scan_types` | `[]string` | `["ENHANCED"]` | Allowed registry-level scan modes | +| `required_repository_tags` | `[]string` | `["Environment","Owner"]` | Tag keys every repository must carry | +| `required_tag_values` | `object` | `{}` | Enforce specific values for certain tags | +| `image_lookback_days` | `number` | `90` | Days back to evaluate image digests | +| `max_high_finding_count` | `number` | `0` | Maximum HIGH severity findings allowed per image | + +## Local development + +```bash +# Build the binary +make build + +# Run unit tests +make test + +# Run against a dev account (requires AWS credentials) +make run +``` + +See `examples/agent-config.yaml` for a full configuration reference. diff --git a/examples/agent-config.yaml b/examples/agent-config.yaml new file mode 100644 index 0000000..fd24945 --- /dev/null +++ b/examples/agent-config.yaml @@ -0,0 +1,61 @@ +# plugin-aws-ecr agent configuration examples +# Copy one of the two source variants below and remove the other. + +plugins: + # ----- Option A: local binary (development / CI) ----- + - name: plugin-aws-ecr + source: + type: local + path: /path/to/plugin-aws-ecr/dist/plugin + + # ----- Option B: OCI image (production) ----- + # - name: plugin-aws-ecr + # source: + # type: oci + # image: ghcr.io/container-solutions/plugin-aws-ecr:latest + + config: + # regions: comma-separated list of AWS regions to scan (required) + regions: "us-east-1,eu-west-1" + + # accounts: comma-separated list of account IDs to filter on (optional) + # If omitted all repositories in each region are evaluated. + # accounts: "123456789012,987654321098" + + # policy_labels: JSON object of extra labels added to every Evidence record (optional) + # Useful for tagging evidence by environment, team, or cost centre. + # policy_labels: '{"env":"prod","team":"platform"}' + + policy_paths: + - /path/to/plugin-aws-ecr-policies/dist/bundle.tar.gz + + # policy_data overrides the defaults in policies/data.json at runtime. + policy_data: + # approved_encryption_types: list of allowed ECR encryption types. + # Allowed values: "KMS", "AES256", "KMS_DSSE" + approved_encryption_types: + - "KMS" + + # approved_registry_scan_types: list of allowed registry-level scan modes. + # Allowed values: "BASIC", "ENHANCED" + approved_registry_scan_types: + - "ENHANCED" + + # required_repository_tags: tag keys that every ECR repository must carry. + required_repository_tags: + - "Environment" + - "Owner" + + # required_tag_values: enforce specific values for certain tag keys (optional). + # Example: require Environment tag to be exactly "prod" + # required_tag_values: + # Environment: "prod" + required_tag_values: {} + + # image_lookback_days: how many days back to evaluate image digests for DYNAMIC checks. + # Default 90 days matches the SOC2 audit window. + image_lookback_days: 90 + + # max_high_finding_count: maximum number of HIGH severity findings allowed per image. + # Set to 0 to require zero HIGH findings. Increase with caution. + max_high_finding_count: 0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8d12a94 --- /dev/null +++ b/go.mod @@ -0,0 +1,72 @@ +module github.com/container-solutions/plugin-aws-ecr + +go 1.26.1 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.11 + github.com/aws/aws-sdk-go-v2/config v1.32.19 + github.com/aws/aws-sdk-go-v2/service/ecr v1.58.2 + github.com/compliance-framework/agent v0.7.0 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.7.0 +) + +require ( + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 // indirect + github.com/aws/smithy-go v1.27.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/compliance-framework/api v0.16.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/defenseunicorns/go-oscal v0.7.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/open-policy-agent/opa v1.14.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/valyala/fastjson v1.6.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.32 // 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.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e7424e --- /dev/null +++ b/go.sum @@ -0,0 +1,432 @@ +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.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/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/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +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/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw= +github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs= +github.com/aws/aws-sdk-go-v2/config v1.32.19 h1:qRhIJMbevHUvIE7X4TK8N8zye5+5AhapcslPrvB+qKE= +github.com/aws/aws-sdk-go-v2/config v1.32.19/go.mod h1:RbJ24nfoya63+Mf5VI+CGCGk9vEdv28xPeii+gojRYs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.18 h1:GcXQz2M/0ZvMo0v5DakUqbDBeBM1ZNaivkolEF4Esgw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.18/go.mod h1:sHJ06tMGcD3ZpmMyJqV+VBsGilhSIZPIN+ZFy5Dg0C4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 h1:FQm5ApnyzkuJdXLGskPce83CK1CQKC4RUnIHKVe4BU4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24/go.mod h1:JsC7dqQc55MlZ5mvNsDMMge71u8pVcSzU3RNz2h/5yQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4= +github.com/aws/aws-sdk-go-v2/service/ecr v1.58.2 h1:sSfN1WfNyYjV0CQC8moHP4uxgNXSUxDT0X2vXbcJSbc= +github.com/aws/aws-sdk-go-v2/service/ecr v1.58.2/go.mod h1:CJsIIgsVqF1YRtS436i4RKrNFlo2LtQ7gORkdhKaj3k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw= +github.com/aws/aws-sdk-go-v2/service/sesv2 v1.59.0 h1:HQYog9wJM8D9aF0bOVzzWbjpWZ7exyjc3rLb7P8Qb8E= +github.com/aws/aws-sdk-go-v2/service/sesv2 v1.59.0/go.mod h1:p0iz0in3/mt3aS2Ovk3aKeOq5vwM/V3prQG9nlBO/OM= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.18/go.mod h1:A9K9qx2l6nK89hp+a350FdGfRkrkH5HdiEjHbiy/Q/c= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 h1:4VD7TIZOGzehrgQ8vDE+1c6BQW4ErZPGY8ohZT5LXEE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1/go.mod h1:er0SFJfdV89Rit5hIJu/EXtv+qC2XMnxoksLmcUFkqM= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 h1:XKnxlM4KZH1gktcsh3zSWc7GW4KivEv/OkifmHOhCUY= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.2/go.mod h1:KJYmkQaFB3SUW2j3aBkPsxNmAb4ZsSOvbvCpuxzHJA0= +github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus= +github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/v39 v39.0.1 h1:RibaT47yiyCRxMOj/l2cvL8cWiWBSqDXHyqsa9sGcCE= +github.com/bytecodealliance/wasmtime-go/v39 v39.0.1/go.mod h1:miR4NYIEBXeDNamZIzpskhJ0z/p8al+lwMWylQ/ZJb4= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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.7.0 h1:ZNuztQKLNvazIqe9QVV9OjERCPOt3GlZ1/wv9iLOwtU= +github.com/compliance-framework/agent v0.7.0/go.mod h1:k6sNhVQXviFHbz/Fe/jOkfBZ+AFLnRPIuOH2aaaCTNo= +github.com/compliance-framework/api v0.16.0 h1:0HO5a5N80ktJLeLD5GVeTk7cK7PO9Xj5WN4SR+KGBH0= +github.com/compliance-framework/api v0.16.0/go.mod h1:BupcN8mQFgB0/2+YShU/r4BUYoGwzSjbz2esdOUaX/4= +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 v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= +github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +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/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/defenseunicorns/go-oscal v0.7.0 h1:Ji9Yw3zEkbUfKZ8Gotoi9ExjUV/h3jmFLJBCYWkDN3E= +github.com/defenseunicorns/go-oscal v0.7.0/go.mod h1:OPuLRz6v7qhSaKIUgr+bK6ykhYq7FpZozSn2cVZJhMs= +github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= +github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +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.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc= +github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +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/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +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-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= +github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +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/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= +github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +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/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/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.14.1 h1:MhurLB9mSbXmojYFCmGbiC1Uagu1+aFAV4XVotDA86M= +github.com/open-policy-agent/opa v1.14.1/go.mod h1:B5gykwJ2l0g0wZS4ClCcpfSSEx51n4NHpTsWfuPwqnQ= +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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/riverqueue/river v0.30.1 h1:lpwmDT3zD+iDtF4tD50e/Y23UHpIeBUffVTDr2khN+s= +github.com/riverqueue/river v0.30.1/go.mod h1:x9tVfiCrbOctSAmaYP00iE5YlO8zh3Y9leFk6wP6aCk= +github.com/riverqueue/river/riverdriver v0.30.1 h1:p04cz/Ald1Js/STZ9qYrY5/TBJgjQeVPFltxidFYBBo= +github.com/riverqueue/river/riverdriver v0.30.1/go.mod h1:WBB9w6LftQtoZgRhNstqhP7MyBKt09XJkzluSNwMMoY= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.30.1 h1:nEStDftvm2jvGlJLliJR+n24PCJsoc4CgGzuop2Yzig= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.30.1/go.mod h1:4oSf8jYWZaEwmJ3R5LmOMiGlV9uuvCWOJ3uyBfTwWCc= +github.com/riverqueue/river/rivershared v0.30.1 h1:ytYlTtMppDV2rJRJ2j55mNf9uQDMPFudOmT4le6/9Ig= +github.com/riverqueue/river/rivershared v0.30.1/go.mod h1:PfmUHWkF6/fJ1CpjC4cG8eKciBXgMuIHgcRcIuHMc34= +github.com/riverqueue/river/rivertype v0.30.1 h1:jR7M5UlkA7KRxEbII+LOkD9oQMMz60AEdHh2We1APHY= +github.com/riverqueue/river/rivertype v0.30.1/go.mod h1:rWpgI59doOWS6zlVocROcwc00fZ1RbzRwsRTU8CDguw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +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.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/slack-go/slack v0.20.0 h1:gbDdbee8+Z2o+DWx05Spq3GzbrLLleiRwHUKs+hZLSU= +github.com/slack-go/slack v0.20.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= +github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +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/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +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/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= +github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +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.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +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/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= +gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..9436dd0 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,44 @@ +package internal + +import ( + "encoding/json" + "fmt" + "strings" +) + +type PluginConfig struct { + Regions []string + Accounts []string + PolicyLabels map[string]string +} + +func ParseConfig(raw map[string]string) (*PluginConfig, error) { + config := &PluginConfig{} + + if v := strings.TrimSpace(raw["regions"]); v != "" { + for _, r := range strings.Split(v, ",") { + if r := strings.TrimSpace(r); r != "" { + config.Regions = append(config.Regions, r) + } + } + } + if len(config.Regions) == 0 { + return nil, fmt.Errorf("config key 'regions' is required") + } + + if v := strings.TrimSpace(raw["accounts"]); v != "" { + for _, a := range strings.Split(v, ",") { + if a := strings.TrimSpace(a); a != "" { + config.Accounts = append(config.Accounts, a) + } + } + } + + if v := strings.TrimSpace(raw["policy_labels"]); v != "" { + if err := json.Unmarshal([]byte(v), &config.PolicyLabels); err != nil { + return nil, fmt.Errorf("could not parse policy_labels: %w", err) + } + } + + return config, nil +} diff --git a/internal/config_test.go b/internal/config_test.go new file mode 100644 index 0000000..9e85325 --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,97 @@ +package internal + +import ( + "testing" +) + +func TestParseConfig_RequiresRegions(t *testing.T) { + _, err := ParseConfig(map[string]string{}) + if err == nil { + t.Fatal("expected error when regions is missing, got nil") + } +} + +func TestParseConfig_SingleRegion(t *testing.T) { + cfg, err := ParseConfig(map[string]string{"regions": "us-east-1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Regions) != 1 || cfg.Regions[0] != "us-east-1" { + t.Fatalf("expected [us-east-1], got %v", cfg.Regions) + } +} + +func TestParseConfig_MultipleRegions(t *testing.T) { + cfg, err := ParseConfig(map[string]string{"regions": "us-east-1, eu-west-1 , ap-southeast-2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"us-east-1", "eu-west-1", "ap-southeast-2"} + if len(cfg.Regions) != len(want) { + t.Fatalf("expected %v, got %v", want, cfg.Regions) + } + for i, r := range want { + if cfg.Regions[i] != r { + t.Errorf("region[%d]: want %q, got %q", i, r, cfg.Regions[i]) + } + } +} + +func TestParseConfig_MultipleAccounts(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "regions": "us-east-1", + "accounts": "111111111111, 222222222222", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"111111111111", "222222222222"} + for i, a := range want { + if cfg.Accounts[i] != a { + t.Errorf("account[%d]: want %q, got %q", i, a, cfg.Accounts[i]) + } + } +} + +func TestParseConfig_PolicyLabels(t *testing.T) { + cfg, err := ParseConfig(map[string]string{ + "regions": "us-east-1", + "policy_labels": `{"env":"prod"}`, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.PolicyLabels["env"] != "prod" { + t.Errorf("expected policy_labels env=prod, got %v", cfg.PolicyLabels) + } +} + +func TestParseConfig_InvalidPolicyLabels(t *testing.T) { + _, err := ParseConfig(map[string]string{ + "regions": "us-east-1", + "policy_labels": `not-json`, + }) + if err == nil { + t.Fatal("expected error for invalid policy_labels JSON, got nil") + } +} + +func TestEvalLabelsStable(t *testing.T) { + repoLabels := repositoryBaseLabels() + if repoLabels["provider"] != "aws" || repoLabels["type"] != "ecr-repository" { + t.Errorf("repository base labels changed: %v", repoLabels) + } + if len(repoLabels) != 2 { + t.Errorf("unexpected extra labels: %v", repoLabels) + } + + regLabels := registryBaseLabels() + if regLabels["provider"] != "aws" || regLabels["type"] != "ecr-registry" { + t.Errorf("registry base labels changed: %v", regLabels) + } + + imgLabels := imageBaseLabels() + if imgLabels["provider"] != "aws" || imgLabels["type"] != "ecr-image" { + t.Errorf("image base labels changed: %v", imgLabels) + } +} diff --git a/internal/data.go b/internal/data.go new file mode 100644 index 0000000..01607da --- /dev/null +++ b/internal/data.go @@ -0,0 +1,380 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/hashicorp/go-hclog" +) + +// ECRClient is the subset of the AWS ECR API used by DataFetcher. +type ECRClient interface { + DescribeRepositories(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) + GetLifecyclePolicy(ctx context.Context, params *ecr.GetLifecyclePolicyInput, optFns ...func(*ecr.Options)) (*ecr.GetLifecyclePolicyOutput, error) + GetRepositoryPolicy(ctx context.Context, params *ecr.GetRepositoryPolicyInput, optFns ...func(*ecr.Options)) (*ecr.GetRepositoryPolicyOutput, error) + ListTagsForResource(ctx context.Context, params *ecr.ListTagsForResourceInput, optFns ...func(*ecr.Options)) (*ecr.ListTagsForResourceOutput, error) + GetRegistryScanningConfiguration(ctx context.Context, params *ecr.GetRegistryScanningConfigurationInput, optFns ...func(*ecr.Options)) (*ecr.GetRegistryScanningConfigurationOutput, error) + DescribeImages(ctx context.Context, params *ecr.DescribeImagesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) + DescribeImageScanFindings(ctx context.Context, params *ecr.DescribeImageScanFindingsInput, optFns ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) +} + +// RepositoryContext holds all fields required by CONFIG ECR compliance policies. +type RepositoryContext struct { + ResourceType string `json:"resource_type"` + RepositoryArn string `json:"repository_arn"` + RepositoryName string `json:"repository_name"` + RegistryID string `json:"registry_id"` + Region string `json:"region"` + AccountID string `json:"account_id"` + ImageTagImmutability string `json:"image_tag_immutability"` + ScanOnPush bool `json:"scan_on_push"` + EncryptionType string `json:"encryption_type"` + KmsKey string `json:"kms_key"` + HasLifecyclePolicy bool `json:"has_lifecycle_policy"` + LifecyclePolicyText string `json:"lifecycle_policy_text"` + HasRepositoryPolicy bool `json:"has_repository_policy"` + RepositoryPolicyText string `json:"repository_policy_text"` + Tags map[string]string `json:"tags"` +} + +// RegistryContext holds account-level scanning configuration for CONFIG policies. +type RegistryContext struct { + ResourceType string `json:"resource_type"` + RegistryID string `json:"registry_id"` + Region string `json:"region"` + AccountID string `json:"account_id"` + RegistryScanType string `json:"registry_scan_type"` + EnhancedScanningEnabled bool `json:"enhanced_scanning_enabled"` +} + +// ImageContext holds image-level scan data for DYNAMIC (90-day lookback) policies. +type ImageContext struct { + ResourceType string `json:"resource_type"` + RepositoryArn string `json:"repository_arn"` + RepositoryName string `json:"repository_name"` + Region string `json:"region"` + AccountID string `json:"account_id"` + ImageDigest string `json:"image_digest"` + ImageTags []string `json:"image_tags"` + ImagePushedAt time.Time `json:"image_pushed_at"` + ScanStatus string `json:"scan_status"` + FindingsCritical int `json:"findings_critical"` + FindingsHigh int `json:"findings_high"` + HasSeverityData bool `json:"has_severity_data"` +} + +// ToOPAInput serialises c to a map[string]interface{} suitable for OPA input. +func (c RepositoryContext) ToOPAInput() (map[string]interface{}, error) { + return toMap(c) +} + +// ToOPAInput serialises c to a map[string]interface{} suitable for OPA input. +func (c RegistryContext) ToOPAInput() (map[string]interface{}, error) { + return toMap(c) +} + +// ToOPAInput serialises c to a map[string]interface{} suitable for OPA input. +func (c ImageContext) ToOPAInput() (map[string]interface{}, error) { + return toMap(c) +} + +func toMap(v interface{}) (map[string]interface{}, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// DataFetcher retrieves ECR data across configured regions. +type DataFetcher struct { + logger hclog.Logger + config *PluginConfig + newClient func(ctx context.Context, region string) (ECRClient, error) +} + +// NewDataFetcher returns a DataFetcher using the standard AWS credential chain. +func NewDataFetcher(logger hclog.Logger, cfg *PluginConfig) *DataFetcher { + return &DataFetcher{ + logger: logger, + config: cfg, + newClient: func(ctx context.Context, region string) (ECRClient, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, err + } + return ecr.NewFromConfig(awsCfg), nil + }, + } +} + +// FetchRepositories returns all private ECR repositories in the given region. +func (df *DataFetcher) FetchRepositories(ctx context.Context, region string) ([]RepositoryContext, error) { + client, err := df.newClient(ctx, region) + if err != nil { + return nil, fmt.Errorf("create ECR client: %w", err) + } + + var repos []RepositoryContext + var nextToken *string + for { + out, err := client.DescribeRepositories(ctx, &ecr.DescribeRepositoriesInput{ + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("DescribeRepositories: %w", err) + } + for _, r := range out.Repositories { + repo, err := df.buildRepositoryContext(ctx, client, region, r) + if err != nil { + return nil, fmt.Errorf("repository %s: %w", aws.ToString(r.RepositoryName), err) + } + repos = append(repos, repo) + } + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + return repos, nil +} + +func (df *DataFetcher) buildRepositoryContext(ctx context.Context, client ECRClient, region string, r types.Repository) (RepositoryContext, error) { + repoName := aws.ToString(r.RepositoryName) + repoARN := aws.ToString(r.RepositoryArn) + + hasLifecycle, lifecycleText, err := df.fetchLifecyclePolicy(ctx, client, repoName) + if err != nil { + return RepositoryContext{}, fmt.Errorf("GetLifecyclePolicy: %w", err) + } + + hasRepoPolicy, repoPolicyText, err := df.fetchRepositoryPolicy(ctx, client, repoName) + if err != nil { + return RepositoryContext{}, fmt.Errorf("GetRepositoryPolicy: %w", err) + } + + tagsOut, err := client.ListTagsForResource(ctx, &ecr.ListTagsForResourceInput{ + ResourceArn: r.RepositoryArn, + }) + if err != nil { + return RepositoryContext{}, fmt.Errorf("ListTagsForResource: %w", err) + } + tags := make(map[string]string, len(tagsOut.Tags)) + for _, t := range tagsOut.Tags { + tags[aws.ToString(t.Key)] = aws.ToString(t.Value) + } + + encType := "" + kmsKey := "" + if r.EncryptionConfiguration != nil { + encType = string(r.EncryptionConfiguration.EncryptionType) + kmsKey = aws.ToString(r.EncryptionConfiguration.KmsKey) + } + + scanOnPush := false + if r.ImageScanningConfiguration != nil { + scanOnPush = r.ImageScanningConfiguration.ScanOnPush + } + + return RepositoryContext{ + ResourceType: "ecr-repository", + RepositoryArn: repoARN, + RepositoryName: repoName, + RegistryID: aws.ToString(r.RegistryId), + Region: region, + AccountID: arnAccountID(repoARN), + ImageTagImmutability: string(r.ImageTagMutability), + ScanOnPush: scanOnPush, + EncryptionType: encType, + KmsKey: kmsKey, + HasLifecyclePolicy: hasLifecycle, + LifecyclePolicyText: lifecycleText, + HasRepositoryPolicy: hasRepoPolicy, + RepositoryPolicyText: repoPolicyText, + Tags: tags, + }, nil +} + +func (df *DataFetcher) fetchLifecyclePolicy(ctx context.Context, client ECRClient, repoName string) (bool, string, error) { + out, err := client.GetLifecyclePolicy(ctx, &ecr.GetLifecyclePolicyInput{ + RepositoryName: aws.String(repoName), + }) + if err != nil { + var nfe *types.LifecyclePolicyNotFoundException + if errors.As(err, &nfe) { + return false, "", nil + } + return false, "", err + } + return true, aws.ToString(out.LifecyclePolicyText), nil +} + +func (df *DataFetcher) fetchRepositoryPolicy(ctx context.Context, client ECRClient, repoName string) (bool, string, error) { + out, err := client.GetRepositoryPolicy(ctx, &ecr.GetRepositoryPolicyInput{ + RepositoryName: aws.String(repoName), + }) + if err != nil { + var nfe *types.RepositoryPolicyNotFoundException + if errors.As(err, &nfe) { + return false, "", nil + } + return false, "", err + } + return true, aws.ToString(out.PolicyText), nil +} + +// FetchRegistryConfig returns the account-level scanning configuration for the given region. +func (df *DataFetcher) FetchRegistryConfig(ctx context.Context, region string) (RegistryContext, error) { + client, err := df.newClient(ctx, region) + if err != nil { + return RegistryContext{}, fmt.Errorf("create ECR client: %w", err) + } + + out, err := client.GetRegistryScanningConfiguration(ctx, &ecr.GetRegistryScanningConfigurationInput{}) + if err != nil { + return RegistryContext{}, fmt.Errorf("GetRegistryScanningConfiguration: %w", err) + } + + scanType := string(out.ScanningConfiguration.ScanType) + enhanced := scanType == "ENHANCED" + + return RegistryContext{ + ResourceType: "ecr-registry", + RegistryID: aws.ToString(out.RegistryId), + Region: region, + AccountID: aws.ToString(out.RegistryId), + RegistryScanType: scanType, + EnhancedScanningEnabled: enhanced, + }, nil +} + +// FetchImages returns ImageContext records for all images pushed within lookbackDays across the given repositories. +func (df *DataFetcher) FetchImages(ctx context.Context, region string, repoNames []string, accountID string, lookbackDays int) ([]ImageContext, error) { + client, err := df.newClient(ctx, region) + if err != nil { + return nil, fmt.Errorf("create ECR client: %w", err) + } + + cutoff := time.Now().UTC().AddDate(0, 0, -lookbackDays) + var images []ImageContext + + for _, repoName := range repoNames { + repoARN := fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", region, accountID, repoName) + repoImages, err := df.fetchImagesForRepo(ctx, client, region, repoName, repoARN, cutoff) + if err != nil { + df.logger.Warn("failed to fetch images for repository", "repository", repoName, "error", err) + continue + } + images = append(images, repoImages...) + } + return images, nil +} + +func (df *DataFetcher) fetchImagesForRepo(ctx context.Context, client ECRClient, region, repoName, repoARN string, cutoff time.Time) ([]ImageContext, error) { + var images []ImageContext + var nextToken *string + + for { + out, err := client.DescribeImages(ctx, &ecr.DescribeImagesInput{ + RepositoryName: aws.String(repoName), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("DescribeImages: %w", err) + } + + for _, detail := range out.ImageDetails { + if detail.ImagePushedAt == nil || detail.ImagePushedAt.Before(cutoff) { + continue + } + digest := aws.ToString(detail.ImageDigest) + if digest == "" { + continue + } + + imgCtx := df.fetchImageScanContext(ctx, client, region, repoName, repoARN, detail) + images = append(images, imgCtx) + } + + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + return images, nil +} + +func (df *DataFetcher) fetchImageScanContext(ctx context.Context, client ECRClient, region, repoName, repoARN string, detail types.ImageDetail) ImageContext { + digest := aws.ToString(detail.ImageDigest) + pushedAt := time.Time{} + if detail.ImagePushedAt != nil { + pushedAt = *detail.ImagePushedAt + } + + tags := detail.ImageTags + if tags == nil { + tags = []string{} + } + + imgCtx := ImageContext{ + ResourceType: "ecr-image", + RepositoryArn: repoARN, + RepositoryName: repoName, + Region: region, + AccountID: arnAccountID(repoARN), + ImageDigest: digest, + ImageTags: tags, + ImagePushedAt: pushedAt, + ScanStatus: "UNSUPPORTED", + FindingsCritical: 0, + FindingsHigh: 0, + HasSeverityData: false, + } + + findingsOut, err := client.DescribeImageScanFindings(ctx, &ecr.DescribeImageScanFindingsInput{ + RepositoryName: aws.String(repoName), + ImageId: &types.ImageIdentifier{ImageDigest: aws.String(digest)}, + }) + if err != nil { + var snfe *types.ScanNotFoundException + if !errors.As(err, &snfe) { + df.logger.Warn("failed to get scan findings", "repository", repoName, "digest", digest, "error", err) + } + return imgCtx + } + + if findingsOut.ImageScanStatus != nil { + imgCtx.ScanStatus = string(findingsOut.ImageScanStatus.Status) + } + + if findingsOut.ImageScanFindings != nil && imgCtx.ScanStatus == "COMPLETE" { + counts := findingsOut.ImageScanFindings.FindingSeverityCounts + imgCtx.FindingsCritical = int(counts["CRITICAL"]) + imgCtx.FindingsHigh = int(counts["HIGH"]) + imgCtx.HasSeverityData = true + } + + return imgCtx +} + +// arnAccountID extracts the 12-digit account ID from an ECR ARN. +// ARN format: arn:aws:ecr:::repository/ +func arnAccountID(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) >= 5 { + return parts[4] + } + return "" +} diff --git a/internal/data_test.go b/internal/data_test.go new file mode 100644 index 0000000..2cf7de6 --- /dev/null +++ b/internal/data_test.go @@ -0,0 +1,470 @@ +package internal + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/hashicorp/go-hclog" +) + +// mockECRClient implements ECRClient for testing. +type mockECRClient struct { + describeRepositories func(context.Context, *ecr.DescribeRepositoriesInput, ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) + getLifecyclePolicy func(context.Context, *ecr.GetLifecyclePolicyInput, ...func(*ecr.Options)) (*ecr.GetLifecyclePolicyOutput, error) + getRepositoryPolicy func(context.Context, *ecr.GetRepositoryPolicyInput, ...func(*ecr.Options)) (*ecr.GetRepositoryPolicyOutput, error) + listTagsForResource func(context.Context, *ecr.ListTagsForResourceInput, ...func(*ecr.Options)) (*ecr.ListTagsForResourceOutput, error) + getRegistryScanningConfig func(context.Context, *ecr.GetRegistryScanningConfigurationInput, ...func(*ecr.Options)) (*ecr.GetRegistryScanningConfigurationOutput, error) + describeImages func(context.Context, *ecr.DescribeImagesInput, ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) + describeImageScanFindings func(context.Context, *ecr.DescribeImageScanFindingsInput, ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) +} + +func (m *mockECRClient) DescribeRepositories(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return m.describeRepositories(ctx, params, optFns...) +} +func (m *mockECRClient) GetLifecyclePolicy(ctx context.Context, params *ecr.GetLifecyclePolicyInput, optFns ...func(*ecr.Options)) (*ecr.GetLifecyclePolicyOutput, error) { + return m.getLifecyclePolicy(ctx, params, optFns...) +} +func (m *mockECRClient) GetRepositoryPolicy(ctx context.Context, params *ecr.GetRepositoryPolicyInput, optFns ...func(*ecr.Options)) (*ecr.GetRepositoryPolicyOutput, error) { + return m.getRepositoryPolicy(ctx, params, optFns...) +} +func (m *mockECRClient) ListTagsForResource(ctx context.Context, params *ecr.ListTagsForResourceInput, optFns ...func(*ecr.Options)) (*ecr.ListTagsForResourceOutput, error) { + return m.listTagsForResource(ctx, params, optFns...) +} +func (m *mockECRClient) GetRegistryScanningConfiguration(ctx context.Context, params *ecr.GetRegistryScanningConfigurationInput, optFns ...func(*ecr.Options)) (*ecr.GetRegistryScanningConfigurationOutput, error) { + return m.getRegistryScanningConfig(ctx, params, optFns...) +} +func (m *mockECRClient) DescribeImages(ctx context.Context, params *ecr.DescribeImagesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) { + return m.describeImages(ctx, params, optFns...) +} +func (m *mockECRClient) DescribeImageScanFindings(ctx context.Context, params *ecr.DescribeImageScanFindingsInput, optFns ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + return m.describeImageScanFindings(ctx, params, optFns...) +} + +func newTestFetcher(client ECRClient) *DataFetcher { + return &DataFetcher{ + logger: hclog.NewNullLogger(), + config: &PluginConfig{Regions: []string{"us-east-1"}}, + newClient: func(_ context.Context, _ string) (ECRClient, error) { + return client, nil + }, + } +} + +func noopPerRepoMock() *mockECRClient { + return &mockECRClient{ + getLifecyclePolicy: func(_ context.Context, _ *ecr.GetLifecyclePolicyInput, _ ...func(*ecr.Options)) (*ecr.GetLifecyclePolicyOutput, error) { + return nil, &types.LifecyclePolicyNotFoundException{} + }, + getRepositoryPolicy: func(_ context.Context, _ *ecr.GetRepositoryPolicyInput, _ ...func(*ecr.Options)) (*ecr.GetRepositoryPolicyOutput, error) { + return nil, &types.RepositoryPolicyNotFoundException{} + }, + listTagsForResource: func(_ context.Context, _ *ecr.ListTagsForResourceInput, _ ...func(*ecr.Options)) (*ecr.ListTagsForResourceOutput, error) { + return &ecr.ListTagsForResourceOutput{}, nil + }, + } +} + +func TestFetchRepositories_Empty(t *testing.T) { + mock := noopPerRepoMock() + mock.describeRepositories = func(_ context.Context, _ *ecr.DescribeRepositoriesInput, _ ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return &ecr.DescribeRepositoriesOutput{}, nil + } + f := newTestFetcher(mock) + repos, err := f.FetchRepositories(context.Background(), "us-east-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(repos)) + } +} + +func TestFetchRepositories_SingleRepo(t *testing.T) { + arn := "arn:aws:ecr:us-east-1:123456789012:repository/my-app" + mock := noopPerRepoMock() + mock.describeRepositories = func(_ context.Context, _ *ecr.DescribeRepositoriesInput, _ ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return &ecr.DescribeRepositoriesOutput{ + Repositories: []types.Repository{ + { + RepositoryArn: aws.String(arn), + RepositoryName: aws.String("my-app"), + RegistryId: aws.String("123456789012"), + ImageTagMutability: types.ImageTagMutabilityImmutable, + ImageScanningConfiguration: &types.ImageScanningConfiguration{ + ScanOnPush: true, + }, + EncryptionConfiguration: &types.EncryptionConfiguration{ + EncryptionType: types.EncryptionTypeKms, + KmsKey: aws.String("arn:aws:kms:us-east-1:123456789012:key/abc"), + }, + }, + }, + }, nil + } + mock.listTagsForResource = func(_ context.Context, _ *ecr.ListTagsForResourceInput, _ ...func(*ecr.Options)) (*ecr.ListTagsForResourceOutput, error) { + return &ecr.ListTagsForResourceOutput{ + Tags: []types.Tag{ + {Key: aws.String("Environment"), Value: aws.String("prod")}, + }, + }, nil + } + + f := newTestFetcher(mock) + repos, err := f.FetchRepositories(context.Background(), "us-east-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(repos)) + } + r := repos[0] + if r.ResourceType != "ecr-repository" { + t.Errorf("resource_type: want ecr-repository, got %q", r.ResourceType) + } + if r.RepositoryArn != arn { + t.Errorf("arn: want %q, got %q", arn, r.RepositoryArn) + } + if r.AccountID != "123456789012" { + t.Errorf("account_id: want 123456789012, got %q", r.AccountID) + } + if !r.ScanOnPush { + t.Error("scan_on_push: want true, got false") + } + if r.ImageTagImmutability != "IMMUTABLE" { + t.Errorf("image_tag_immutability: want IMMUTABLE, got %q", r.ImageTagImmutability) + } + if r.EncryptionType != "KMS" { + t.Errorf("encryption_type: want KMS, got %q", r.EncryptionType) + } + if r.HasLifecyclePolicy { + t.Error("has_lifecycle_policy: want false for not-found") + } + if r.HasRepositoryPolicy { + t.Error("has_repository_policy: want false for not-found") + } + if r.Tags["Environment"] != "prod" { + t.Errorf("tags: expected Environment=prod, got %v", r.Tags) + } +} + +func TestFetchRepositories_WithLifecycleAndPolicy(t *testing.T) { + arn := "arn:aws:ecr:us-east-1:123456789012:repository/prod-app" + mock := noopPerRepoMock() + mock.describeRepositories = func(_ context.Context, _ *ecr.DescribeRepositoriesInput, _ ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return &ecr.DescribeRepositoriesOutput{ + Repositories: []types.Repository{ + { + RepositoryArn: aws.String(arn), + RepositoryName: aws.String("prod-app"), + RegistryId: aws.String("123456789012"), + ImageScanningConfiguration: &types.ImageScanningConfiguration{ScanOnPush: false}, + EncryptionConfiguration: &types.EncryptionConfiguration{EncryptionType: types.EncryptionTypeAes256}, + }, + }, + }, nil + } + mock.getLifecyclePolicy = func(_ context.Context, _ *ecr.GetLifecyclePolicyInput, _ ...func(*ecr.Options)) (*ecr.GetLifecyclePolicyOutput, error) { + return &ecr.GetLifecyclePolicyOutput{ + LifecyclePolicyText: aws.String(`{"rules":[]}`), + }, nil + } + mock.getRepositoryPolicy = func(_ context.Context, _ *ecr.GetRepositoryPolicyInput, _ ...func(*ecr.Options)) (*ecr.GetRepositoryPolicyOutput, error) { + return &ecr.GetRepositoryPolicyOutput{ + PolicyText: aws.String(`{"Version":"2012-10-17","Statement":[]}`), + }, nil + } + + f := newTestFetcher(mock) + repos, err := f.FetchRepositories(context.Background(), "us-east-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + r := repos[0] + if !r.HasLifecyclePolicy { + t.Error("has_lifecycle_policy: want true") + } + if r.LifecyclePolicyText == "" { + t.Error("lifecycle_policy_text: want non-empty") + } + if !r.HasRepositoryPolicy { + t.Error("has_repository_policy: want true") + } + if r.EncryptionType != "AES256" { + t.Errorf("encryption_type: want AES256, got %q", r.EncryptionType) + } +} + +func TestFetchRepositories_Pagination(t *testing.T) { + callCount := 0 + mock := noopPerRepoMock() + mock.describeRepositories = func(_ context.Context, params *ecr.DescribeRepositoriesInput, _ ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + callCount++ + if callCount == 1 { + return &ecr.DescribeRepositoriesOutput{ + Repositories: []types.Repository{{ + RepositoryArn: aws.String("arn:aws:ecr:us-east-1:123456789012:repository/repo-a"), + RepositoryName: aws.String("repo-a"), + RegistryId: aws.String("123456789012"), + }}, + NextToken: aws.String("page2"), + }, nil + } + return &ecr.DescribeRepositoriesOutput{ + Repositories: []types.Repository{{ + RepositoryArn: aws.String("arn:aws:ecr:us-east-1:123456789012:repository/repo-b"), + RepositoryName: aws.String("repo-b"), + RegistryId: aws.String("123456789012"), + }}, + }, nil + } + + f := newTestFetcher(mock) + repos, err := f.FetchRepositories(context.Background(), "us-east-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(repos) != 2 { + t.Errorf("expected 2 repos, got %d", len(repos)) + } + if callCount != 2 { + t.Errorf("expected 2 DescribeRepositories calls, got %d", callCount) + } +} + +func TestFetchRegistryConfig_Enhanced(t *testing.T) { + mock := noopPerRepoMock() + mock.getRegistryScanningConfig = func(_ context.Context, _ *ecr.GetRegistryScanningConfigurationInput, _ ...func(*ecr.Options)) (*ecr.GetRegistryScanningConfigurationOutput, error) { + return &ecr.GetRegistryScanningConfigurationOutput{ + RegistryId: aws.String("123456789012"), + ScanningConfiguration: &types.RegistryScanningConfiguration{ + ScanType: types.ScanTypeEnhanced, + }, + }, nil + } + + f := newTestFetcher(mock) + reg, err := f.FetchRegistryConfig(context.Background(), "us-east-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg.ResourceType != "ecr-registry" { + t.Errorf("resource_type: want ecr-registry, got %q", reg.ResourceType) + } + if reg.RegistryScanType != "ENHANCED" { + t.Errorf("registry_scan_type: want ENHANCED, got %q", reg.RegistryScanType) + } + if !reg.EnhancedScanningEnabled { + t.Error("enhanced_scanning_enabled: want true") + } +} + +func TestFetchImages_WithinLookback(t *testing.T) { + now := time.Now().UTC() + recentDigest := "sha256:aaaa" + oldDigest := "sha256:bbbb" + + mock := noopPerRepoMock() + mock.describeImages = func(_ context.Context, params *ecr.DescribeImagesInput, _ ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) { + recentTime := now.Add(-10 * 24 * time.Hour) // 10 days ago — in window + oldTime := now.Add(-100 * 24 * time.Hour) // 100 days ago — outside window + return &ecr.DescribeImagesOutput{ + ImageDetails: []types.ImageDetail{ + {ImageDigest: aws.String(recentDigest), ImagePushedAt: &recentTime, ImageTags: []string{"latest"}}, + {ImageDigest: aws.String(oldDigest), ImagePushedAt: &oldTime}, + }, + }, nil + } + mock.describeImageScanFindings = func(_ context.Context, params *ecr.DescribeImageScanFindingsInput, _ ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + scanTime := now.Add(-9 * 24 * time.Hour) + return &ecr.DescribeImageScanFindingsOutput{ + ImageScanStatus: &types.ImageScanStatus{Status: types.ScanStatusComplete}, + ImageScanFindings: &types.ImageScanFindings{ + ImageScanCompletedAt: &scanTime, + FindingSeverityCounts: map[string]int32{"CRITICAL": 0, "HIGH": 2}, + }, + }, nil + } + + f := newTestFetcher(mock) + images, err := f.FetchImages(context.Background(), "us-east-1", []string{"my-repo"}, "123456789012", 90) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(images) != 1 { + t.Fatalf("expected 1 image (old one filtered), got %d", len(images)) + } + img := images[0] + if img.ImageDigest != recentDigest { + t.Errorf("digest: want %q, got %q", recentDigest, img.ImageDigest) + } + if img.ScanStatus != "COMPLETE" { + t.Errorf("scan_status: want COMPLETE, got %q", img.ScanStatus) + } + if img.FindingsHigh != 2 { + t.Errorf("findings_high: want 2, got %d", img.FindingsHigh) + } + if !img.HasSeverityData { + t.Error("has_severity_data: want true") + } +} + +func TestFetchImages_NoScan(t *testing.T) { + now := time.Now().UTC() + pushedAt := now.Add(-5 * 24 * time.Hour) + + mock := noopPerRepoMock() + mock.describeImages = func(_ context.Context, _ *ecr.DescribeImagesInput, _ ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) { + return &ecr.DescribeImagesOutput{ + ImageDetails: []types.ImageDetail{ + {ImageDigest: aws.String("sha256:cccc"), ImagePushedAt: &pushedAt}, + }, + }, nil + } + mock.describeImageScanFindings = func(_ context.Context, _ *ecr.DescribeImageScanFindingsInput, _ ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + return nil, &types.ScanNotFoundException{} + } + + f := newTestFetcher(mock) + images, err := f.FetchImages(context.Background(), "us-east-1", []string{"my-repo"}, "123456789012", 90) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(images) != 1 { + t.Fatalf("expected 1 image, got %d", len(images)) + } + img := images[0] + if img.ScanStatus != "UNSUPPORTED" { + t.Errorf("scan_status: want UNSUPPORTED for no scan, got %q", img.ScanStatus) + } + if img.HasSeverityData { + t.Error("has_severity_data: want false when no scan") + } +} + +func TestFetchImages_Pagination(t *testing.T) { + now := time.Now().UTC() + pushedAt := now.Add(-1 * 24 * time.Hour) + callCount := 0 + + mock := noopPerRepoMock() + mock.describeImages = func(_ context.Context, params *ecr.DescribeImagesInput, _ ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) { + callCount++ + if callCount == 1 { + return &ecr.DescribeImagesOutput{ + ImageDetails: []types.ImageDetail{{ImageDigest: aws.String("sha256:page1"), ImagePushedAt: &pushedAt}}, + NextToken: aws.String("tok"), + }, nil + } + return &ecr.DescribeImagesOutput{ + ImageDetails: []types.ImageDetail{{ImageDigest: aws.String("sha256:page2"), ImagePushedAt: &pushedAt}}, + }, nil + } + mock.describeImageScanFindings = func(_ context.Context, _ *ecr.DescribeImageScanFindingsInput, _ ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + return nil, &types.ScanNotFoundException{} + } + + f := newTestFetcher(mock) + images, err := f.FetchImages(context.Background(), "us-east-1", []string{"my-repo"}, "123456789012", 90) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(images) != 2 { + t.Errorf("expected 2 images, got %d", len(images)) + } + if callCount != 2 { + t.Errorf("expected 2 DescribeImages calls, got %d", callCount) + } +} + +func TestArnAccountID(t *testing.T) { + cases := []struct { + arn string + want string + }{ + {"arn:aws:ecr:us-east-1:123456789012:repository/foo", "123456789012"}, + {"arn:aws:ecr:eu-west-1:999999999999:repository/bar", "999999999999"}, + {"invalid", ""}, + } + for _, tc := range cases { + got := arnAccountID(tc.arn) + if got != tc.want { + t.Errorf("arnAccountID(%q): want %q, got %q", tc.arn, tc.want, got) + } + } +} + +func TestRepositoryContext_LifecyclePolicyNotFound(t *testing.T) { + mock := noopPerRepoMock() + mock.describeRepositories = func(_ context.Context, _ *ecr.DescribeRepositoriesInput, _ ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return &ecr.DescribeRepositoriesOutput{ + Repositories: []types.Repository{{ + RepositoryArn: aws.String("arn:aws:ecr:us-east-1:123456789012:repository/no-policy"), + RepositoryName: aws.String("no-policy"), + RegistryId: aws.String("123456789012"), + }}, + }, nil + } + mock.getLifecyclePolicy = func(_ context.Context, _ *ecr.GetLifecyclePolicyInput, _ ...func(*ecr.Options)) (*ecr.GetLifecyclePolicyOutput, error) { + return nil, &types.LifecyclePolicyNotFoundException{Message: aws.String("not found")} + } + + f := newTestFetcher(mock) + repos, err := f.FetchRepositories(context.Background(), "us-east-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repos[0].HasLifecyclePolicy { + t.Error("has_lifecycle_policy: want false when LifecyclePolicyNotFoundException") + } +} + +func TestRepositoryContext_ToOPAInput(t *testing.T) { + repo := RepositoryContext{ + ResourceType: "ecr-repository", + RepositoryArn: "arn:aws:ecr:us-east-1:123456789012:repository/test", + RepositoryName: "test", + ScanOnPush: true, + Tags: map[string]string{"env": "prod"}, + } + m, err := repo.ToOPAInput() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m["resource_type"] != "ecr-repository" { + t.Errorf("resource_type: want ecr-repository, got %v", m["resource_type"]) + } + if m["scan_on_push"] != true { + t.Errorf("scan_on_push: want true, got %v", m["scan_on_push"]) + } +} + +// Ensure ScanNotFoundException is distinguishable from other errors. +func TestFetchImages_NonScanError(t *testing.T) { + now := time.Now().UTC() + pushedAt := now.Add(-1 * 24 * time.Hour) + + mock := noopPerRepoMock() + mock.describeImages = func(_ context.Context, _ *ecr.DescribeImagesInput, _ ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) { + return &ecr.DescribeImagesOutput{ + ImageDetails: []types.ImageDetail{{ImageDigest: aws.String("sha256:dddd"), ImagePushedAt: &pushedAt}}, + }, nil + } + mock.describeImageScanFindings = func(_ context.Context, _ *ecr.DescribeImageScanFindingsInput, _ ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + return nil, errors.New("some other error") + } + + f := newTestFetcher(mock) + images, err := f.FetchImages(context.Background(), "us-east-1", []string{"my-repo"}, "123456789012", 90) + if err != nil { + t.Fatalf("unexpected error (non-scan errors should be logged, not returned): %v", err) + } + // Image should still be returned with default scan status + if len(images) != 1 { + t.Fatalf("expected 1 image, got %d", len(images)) + } +} diff --git a/internal/eval.go b/internal/eval.go new file mode 100644 index 0000000..c87f172 --- /dev/null +++ b/internal/eval.go @@ -0,0 +1,261 @@ +package internal + +import ( + "context" + "errors" + "fmt" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" +) + +type PolicyEvaluator struct { + ctx context.Context + logger hclog.Logger + stepActivities []*proto.Activity +} + +func NewPolicyEvaluator(ctx context.Context, logger hclog.Logger, stepActivities []*proto.Activity) *PolicyEvaluator { + return &PolicyEvaluator{ctx: ctx, logger: logger, stepActivities: stepActivities} +} + +// EvalRepository evaluates all policyPaths against a repository and returns Evidence. +func (pe *PolicyEvaluator) EvalRepository(ctx context.Context, repo RepositoryContext, policyPaths []string, policyData map[string]interface{}, extraLabels map[string]string) ([]*proto.Evidence, error) { + input, err := repo.ToOPAInput() + if err != nil { + return nil, fmt.Errorf("serialising repository %s: %w", repo.RepositoryArn, err) + } + + componentID := "common-components/aws-ecr-repository" + inventoryID := fmt.Sprintf("aws-ecr-repository/%s/%s/%s", repo.AccountID, repo.Region, repo.RepositoryName) + + labels := MergeMaps(extraLabels, repositoryBaseLabels(), map[string]string{ + "repository_arn": repo.RepositoryArn, + "repository_name": repo.RepositoryName, + "region": repo.Region, + "account_id": repo.AccountID, + }) + + actors := ecrActors("AWS ECR Plugin") + components := []*proto.Component{ + { + Identifier: componentID, + Type: "service", + Title: "Amazon Elastic Container Registry", + Description: "Amazon ECR is a fully managed container registry that makes it easy to store, manage, share, and deploy container images and artifacts.", + Purpose: "To store and manage container images with access controls, image scanning, and lifecycle policies.", + }, + } + inventory := []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: "container-registry", + Title: fmt.Sprintf("ECR Repository [%s]", repo.RepositoryName), + Props: []*proto.Property{ + {Name: "repository_arn", Value: repo.RepositoryArn}, + {Name: "repository_name", Value: repo.RepositoryName}, + {Name: "region", Value: repo.Region}, + {Name: "encryption_type", Value: repo.EncryptionType}, + {Name: "image_tag_immutability", Value: repo.ImageTagImmutability}, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{{Identifier: componentID}}, + }, + } + subjects := []*proto.Subject{ + {Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, Identifier: componentID}, + {Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, Identifier: inventoryID}, + } + + return pe.runPolicies(ctx, policyPaths, input, labels, subjects, components, inventory, actors, policyData, repo.RepositoryName) +} + +// EvalRegistry evaluates all policyPaths against a registry (account+region) and returns Evidence. +func (pe *PolicyEvaluator) EvalRegistry(ctx context.Context, registry RegistryContext, policyPaths []string, policyData map[string]interface{}, extraLabels map[string]string) ([]*proto.Evidence, error) { + input, err := registry.ToOPAInput() + if err != nil { + return nil, fmt.Errorf("serialising registry %s/%s: %w", registry.AccountID, registry.Region, err) + } + + componentID := "common-components/aws-ecr-registry" + inventoryID := fmt.Sprintf("aws-ecr-registry/%s/%s", registry.AccountID, registry.Region) + + labels := MergeMaps(extraLabels, registryBaseLabels(), map[string]string{ + "registry_id": registry.RegistryID, + "region": registry.Region, + "account_id": registry.AccountID, + }) + + actors := ecrActors("AWS ECR Plugin") + components := []*proto.Component{ + { + Identifier: componentID, + Type: "service", + Title: "Amazon ECR Registry", + Description: "The ECR registry is the account-level container image registry that hosts all private repositories.", + Purpose: "To provide account-level scanning configuration and access controls across all ECR repositories.", + }, + } + inventory := []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: "container-registry", + Title: fmt.Sprintf("ECR Registry [%s/%s]", registry.AccountID, registry.Region), + Props: []*proto.Property{ + {Name: "registry_id", Value: registry.RegistryID}, + {Name: "region", Value: registry.Region}, + {Name: "registry_scan_type", Value: registry.RegistryScanType}, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{{Identifier: componentID}}, + }, + } + subjects := []*proto.Subject{ + {Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, Identifier: componentID}, + {Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, Identifier: inventoryID}, + } + + return pe.runPolicies(ctx, policyPaths, input, labels, subjects, components, inventory, actors, policyData, fmt.Sprintf("%s/%s", registry.AccountID, registry.Region)) +} + +// EvalImage evaluates all policyPaths against an image digest and returns Evidence. +func (pe *PolicyEvaluator) EvalImage(ctx context.Context, image ImageContext, policyPaths []string, policyData map[string]interface{}, extraLabels map[string]string) ([]*proto.Evidence, error) { + input, err := image.ToOPAInput() + if err != nil { + return nil, fmt.Errorf("serialising image %s: %w", image.ImageDigest, err) + } + + componentID := "common-components/aws-ecr-image" + digestShort := image.ImageDigest + if len(digestShort) > 19 { + digestShort = digestShort[:19] + } + inventoryID := fmt.Sprintf("aws-ecr-image/%s/%s/%s", image.AccountID, image.RepositoryName, digestShort) + + labels := MergeMaps(extraLabels, imageBaseLabels(), map[string]string{ + "repository_arn": image.RepositoryArn, + "repository_name": image.RepositoryName, + "image_digest": image.ImageDigest, + "region": image.Region, + "account_id": image.AccountID, + }) + + actors := ecrActors("AWS ECR Plugin") + components := []*proto.Component{ + { + Identifier: componentID, + Type: "artifact", + Title: "Amazon ECR Container Image", + Description: "A container image stored in Amazon ECR, subject to vulnerability scanning and lifecycle policies.", + Purpose: "To provide a scannable, immutable artifact reference for container workload compliance evaluation.", + }, + } + inventory := []*proto.InventoryItem{ + { + Identifier: inventoryID, + Type: "container-image", + Title: fmt.Sprintf("ECR Image [%s@%s]", image.RepositoryName, digestShort), + Props: []*proto.Property{ + {Name: "repository_arn", Value: image.RepositoryArn}, + {Name: "image_digest", Value: image.ImageDigest}, + {Name: "scan_status", Value: image.ScanStatus}, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{{Identifier: componentID}}, + }, + } + subjects := []*proto.Subject{ + {Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, Identifier: componentID}, + {Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, Identifier: inventoryID}, + } + + return pe.runPolicies(ctx, policyPaths, input, labels, subjects, components, inventory, actors, policyData, fmt.Sprintf("%s@%s", image.RepositoryName, digestShort)) +} + +func (pe *PolicyEvaluator) runPolicies( + ctx context.Context, + policyPaths []string, + input map[string]interface{}, + labels map[string]string, + subjects []*proto.Subject, + components []*proto.Component, + inventory []*proto.InventoryItem, + actors []*proto.OriginActor, + policyData map[string]interface{}, + titleSuffix string, +) ([]*proto.Evidence, error) { + var accumulatedErrors error + evidences := make([]*proto.Evidence, 0) + + for _, policyPath := range policyPaths { + processor := policyManager.NewPolicyProcessor( + pe.logger, + labels, + subjects, + components, + inventory, + actors, + pe.stepActivities, + policyData, + ) + evidence, perr := processor.GenerateResults(ctx, policyPath, input) + for _, ev := range evidence { + ev.Title = fmt.Sprintf("%s [%s]", ev.GetTitle(), titleSuffix) + } + evidences = append(evidences, evidence...) + if perr != nil { + accumulatedErrors = errors.Join(accumulatedErrors, perr) + } + } + return evidences, accumulatedErrors +} + +func ecrActors(pluginTitle string) []*proto.OriginActor { + return []*proto.OriginActor{ + { + Title: "The Continuous Compliance Framework", + Type: "assessment-platform", + Links: []*proto.Link{ + { + Href: "https://compliance-framework.github.io/docs/", + Rel: StringAddressed("reference"), + Text: StringAddressed("The Continuous Compliance Framework"), + }, + }, + }, + { + Title: fmt.Sprintf("Continuous Compliance Framework - %s", pluginTitle), + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/container-solutions/plugin-aws-ecr", + Rel: StringAddressed("reference"), + Text: StringAddressed("The Continuous Compliance Framework AWS ECR Plugin"), + }, + }, + }, + } +} + +// repositoryBaseLabels returns stable identity labels for ECR repository evidence. +// SeededUUID derives the evidence UUID from ALL labels — changing any key breaks evidence continuity. +func repositoryBaseLabels() map[string]string { + return map[string]string{ + "provider": "aws", + "type": "ecr-repository", + } +} + +// registryBaseLabels returns stable identity labels for ECR registry evidence. +func registryBaseLabels() map[string]string { + return map[string]string{ + "provider": "aws", + "type": "ecr-registry", + } +} + +// imageBaseLabels returns stable identity labels for ECR image evidence. +func imageBaseLabels() map[string]string { + return map[string]string{ + "provider": "aws", + "type": "ecr-image", + } +} diff --git a/internal/util.go b/internal/util.go new file mode 100644 index 0000000..87d29f1 --- /dev/null +++ b/internal/util.go @@ -0,0 +1,15 @@ +package internal + +func StringAddressed(str string) *string { + return &str +} + +func MergeMaps(maps ...map[string]string) map[string]string { + result := make(map[string]string) + for _, values := range maps { + for k, v := range values { + result[k] = v + } + } + return result +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..dc69838 --- /dev/null +++ b/main.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/agent/runner/proto" + "github.com/container-solutions/plugin-aws-ecr/internal" + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" +) + +type CompliancePlugin struct { + logger hclog.Logger + config *internal.PluginConfig + policyData map[string]interface{} +} + +func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { + rawConfig := req.GetConfig() + parsedConfig, err := internal.ParseConfig(rawConfig) + if err != nil { + return nil, err + } + l.config = parsedConfig + + if req.GetPolicyData() != nil { + l.policyData = req.GetPolicyData().AsMap() + } else { + l.policyData = nil + } + + return &proto.ConfigureResponse{}, nil +} + +func (l *CompliancePlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { + ctx := context.Background() + subjectTemplates := []*proto.SubjectTemplate{ + { + Name: "ecr-repository", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "ECR Repository {{ .repository_name }} in {{ .account_id }}/{{ .region }}", + DescriptionTemplate: "AWS ECR private repository {{ .repository_name }}.", + PurposeTemplate: "Represents an AWS ECR private repository evaluated for compliance posture.", + IdentityLabelKeys: []string{"account_id", "region", "repository_arn"}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "account_id", Description: "AWS account ID"}, + {Key: "region", Description: "AWS region"}, + {Key: "repository_arn", Description: "ECR repository ARN"}, + {Key: "repository_name", Description: "ECR repository name"}, + }, + }, + { + Name: "ecr-registry", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "ECR Registry {{ .account_id }}/{{ .region }}", + DescriptionTemplate: "AWS ECR account-level registry in {{ .account_id }}/{{ .region }}.", + PurposeTemplate: "Represents an AWS ECR registry evaluated for scanning configuration compliance.", + IdentityLabelKeys: []string{"account_id", "region"}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "account_id", Description: "AWS account ID"}, + {Key: "region", Description: "AWS region"}, + {Key: "registry_id", Description: "ECR registry ID (account ID)"}, + }, + }, + { + Name: "ecr-image", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "ECR Image {{ .repository_name }}@{{ .image_digest }} in {{ .account_id }}/{{ .region }}", + DescriptionTemplate: "Container image {{ .image_digest }} in ECR repository {{ .repository_name }}.", + PurposeTemplate: "Represents a container image digest evaluated for vulnerability scan compliance.", + IdentityLabelKeys: []string{"account_id", "region", "repository_arn", "image_digest"}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "account_id", Description: "AWS account ID"}, + {Key: "region", Description: "AWS region"}, + {Key: "repository_arn", Description: "ECR repository ARN"}, + {Key: "repository_name", Description: "ECR repository name"}, + {Key: "image_digest", Description: "Immutable image digest (sha256:...)"}, + }, + }, + } + return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, subjectTemplates) +} + +func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { + ctx := context.Background() + activities := make([]*proto.Activity, 0) + + if request == nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("eval request is nil") + } + + lookbackDays := 90 + if l.policyData != nil { + if v, ok := l.policyData["image_lookback_days"].(float64); ok && v > 0 { + lookbackDays = int(v) + } + } + + dataFetcher := internal.NewDataFetcher(l.logger, l.config) + policyEvaluator := internal.NewPolicyEvaluator(ctx, l.logger, activities) + + var allEvidences []*proto.Evidence + var evalErrors error + + for _, region := range l.config.Regions { + // CONFIG — repository checks + repos, err := dataFetcher.FetchRepositories(ctx, region) + if err != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("region %s: fetching repositories: %w", region, err) + } + for _, repo := range repos { + evidences, err := policyEvaluator.EvalRepository(ctx, repo, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels) + allEvidences = append(allEvidences, evidences...) + if err != nil { + evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating repository %s: %w", repo.RepositoryName, err)) + } + } + + // CONFIG — registry scanning check (one per region) + registry, err := dataFetcher.FetchRegistryConfig(ctx, region) + if err != nil { + l.logger.Warn("failed to fetch registry scanning config", "region", region, "error", err) + } else { + evidences, err := policyEvaluator.EvalRegistry(ctx, registry, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels) + allEvidences = append(allEvidences, evidences...) + if err != nil { + evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating registry %s/%s: %w", registry.AccountID, region, err)) + } + } + + // DYNAMIC — image scan checks + if len(repos) > 0 { + repoNames := make([]string, len(repos)) + for i, r := range repos { + repoNames[i] = r.RepositoryName + } + accountID := repos[0].AccountID + + images, err := dataFetcher.FetchImages(ctx, region, repoNames, accountID, lookbackDays) + if err != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("region %s: fetching images: %w", region, err) + } + for _, image := range images { + evidences, err := policyEvaluator.EvalImage(ctx, image, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels) + allEvidences = append(allEvidences, evidences...) + if err != nil { + evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating image %s@%s: %w", image.RepositoryName, image.ImageDigest, err)) + } + } + } + } + + if err := apiHelper.CreateEvidence(ctx, allEvidences); err != nil { + l.logger.Error("Error creating evidence", "error", err) + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err + } + + if evalErrors != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, evalErrors + } + + return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil +} + +func main() { + logger := hclog.New(&hclog.LoggerOptions{ + Level: hclog.Debug, + JSONFormat: true, + }) + + compliancePluginObj := &CompliancePlugin{ + logger: logger, + } + logger.Debug("initiating plugin-aws-ecr") + + goplugin.Serve(&goplugin.ServeConfig{ + HandshakeConfig: runner.HandshakeConfig, + Plugins: map[string]goplugin.Plugin{ + "runner": &runner.RunnerV2GRPCPlugin{ + Impl: compliancePluginObj, + }, + }, + GRPCServer: goplugin.DefaultGRPCServer, + }) +} From 284b18a1b6ce34c3eab58975473acf9b209d30b7 Mon Sep 17 00:00:00 2001 From: James Salt Date: Wed, 3 Jun 2026 14:58:13 +0100 Subject: [PATCH 2/2] BCH-1296: Address PR review comments on plugin-aws-ecr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml: add workflow-level permissions: contents: read - Makefile: preflight checks on run target for agent binary and config - go.mod: bump go directive to 1.26.3; x/crypto→0.52.0, x/net→0.55.0, x/sys→0.45.0 - config_test.go: guard account-slice length before index loop - data.go: only set HasSeverityData when FindingSeverityCounts is non-nil - data_test.go: add TestFetchImages_ScanCompleteNilCounts - eval.go: fix repo URL to compliance-framework org; include region + full digest in image inventoryID - examples/agent-config.yaml: single plugin block with inline source.type comment Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 +++ Makefile | 2 ++ examples/agent-config.yaml | 14 +++++------- go.mod | 10 ++++---- go.sum | 24 +++++++++---------- internal/config_test.go | 3 +++ internal/data.go | 8 ++++--- internal/data_test.go | 47 ++++++++++++++++++++++++++++++++++++++ internal/eval.go | 4 ++-- 9 files changed, 85 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9189a0b..b6b1fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: ["**"] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 034fd38..45b4bc8 100644 --- a/Makefile +++ b/Makefile @@ -15,4 +15,6 @@ build: clean ## Build the plugin binary @go build -o dist/plugin main.go run: build ## Run the agent with the built plugin + @if [ ! -x ../agent/dist/concom ]; then echo "ERROR: ../agent/dist/concom not found or not executable — build the agent first"; exit 1; fi + @if [ ! -r ./.config/config.yaml ]; then echo "ERROR: ./.config/config.yaml not found — copy examples/agent-config.yaml to .config/config.yaml and fill in your settings"; exit 1; fi @../agent/dist/./concom agent --config ./.config/config.yaml diff --git a/examples/agent-config.yaml b/examples/agent-config.yaml index fd24945..738e3fe 100644 --- a/examples/agent-config.yaml +++ b/examples/agent-config.yaml @@ -1,18 +1,16 @@ -# plugin-aws-ecr agent configuration examples -# Copy one of the two source variants below and remove the other. +# plugin-aws-ecr agent configuration +# Choose ONE source variant by setting source.type to either "local" or "oci". plugins: - # ----- Option A: local binary (development / CI) ----- - name: plugin-aws-ecr source: + # Local binary (development / CI) type: local path: /path/to/plugin-aws-ecr/dist/plugin - # ----- Option B: OCI image (production) ----- - # - name: plugin-aws-ecr - # source: - # type: oci - # image: ghcr.io/container-solutions/plugin-aws-ecr:latest + # OCI image (production) — replace the block above with these two lines: + # type: oci + # image: ghcr.io/compliance-framework/plugin-aws-ecr:latest config: # regions: comma-separated list of AWS regions to scan (required) diff --git a/go.mod b/go.mod index 8d12a94..646b8bc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/container-solutions/plugin-aws-ecr -go 1.26.1 +go 1.26.3 require ( github.com/aws/aws-sdk-go-v2 v1.41.11 @@ -60,11 +60,11 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 4e7424e..195c7f0 100644 --- a/go.sum +++ b/go.sum @@ -378,12 +378,12 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -394,14 +394,14 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= diff --git a/internal/config_test.go b/internal/config_test.go index 9e85325..476db7c 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -46,6 +46,9 @@ func TestParseConfig_MultipleAccounts(t *testing.T) { t.Fatalf("unexpected error: %v", err) } want := []string{"111111111111", "222222222222"} + if got, wantLen := len(cfg.Accounts), len(want); got != wantLen { + t.Fatalf("accounts: want %d entries, got %d", wantLen, got) + } for i, a := range want { if cfg.Accounts[i] != a { t.Errorf("account[%d]: want %q, got %q", i, a, cfg.Accounts[i]) diff --git a/internal/data.go b/internal/data.go index 01607da..3b5cf56 100644 --- a/internal/data.go +++ b/internal/data.go @@ -361,9 +361,11 @@ func (df *DataFetcher) fetchImageScanContext(ctx context.Context, client ECRClie if findingsOut.ImageScanFindings != nil && imgCtx.ScanStatus == "COMPLETE" { counts := findingsOut.ImageScanFindings.FindingSeverityCounts - imgCtx.FindingsCritical = int(counts["CRITICAL"]) - imgCtx.FindingsHigh = int(counts["HIGH"]) - imgCtx.HasSeverityData = true + if counts != nil { + imgCtx.FindingsCritical = int(counts["CRITICAL"]) + imgCtx.FindingsHigh = int(counts["HIGH"]) + imgCtx.HasSeverityData = true + } } return imgCtx diff --git a/internal/data_test.go b/internal/data_test.go index 2cf7de6..3db573a 100644 --- a/internal/data_test.go +++ b/internal/data_test.go @@ -468,3 +468,50 @@ func TestFetchImages_NonScanError(t *testing.T) { t.Fatalf("expected 1 image, got %d", len(images)) } } + +// TestFetchImages_ScanCompleteNilCounts verifies that a COMPLETE scan with a nil +// FindingSeverityCounts map does not set HasSeverityData and leaves counts at zero. +func TestFetchImages_ScanCompleteNilCounts(t *testing.T) { + now := time.Now().UTC() + pushedAt := now.Add(-1 * 24 * time.Hour) + + mock := noopPerRepoMock() + mock.describeImages = func(_ context.Context, _ *ecr.DescribeImagesInput, _ ...func(*ecr.Options)) (*ecr.DescribeImagesOutput, error) { + return &ecr.DescribeImagesOutput{ + ImageDetails: []types.ImageDetail{{ImageDigest: aws.String("sha256:nilcounts"), ImagePushedAt: &pushedAt}}, + }, nil + } + mock.describeImageScanFindings = func(_ context.Context, _ *ecr.DescribeImageScanFindingsInput, _ ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + scanTime := now.Add(-1 * time.Hour) + return &ecr.DescribeImageScanFindingsOutput{ + ImageScanStatus: &types.ImageScanStatus{Status: types.ScanStatusComplete}, + // FindingSeverityCounts deliberately nil + ImageScanFindings: &types.ImageScanFindings{ + ImageScanCompletedAt: &scanTime, + FindingSeverityCounts: nil, + }, + }, nil + } + + f := newTestFetcher(mock) + images, err := f.FetchImages(context.Background(), "us-east-1", []string{"my-repo"}, "123456789012", 90) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(images) != 1 { + t.Fatalf("expected 1 image, got %d", len(images)) + } + img := images[0] + if img.ScanStatus != "COMPLETE" { + t.Errorf("scan_status: want COMPLETE, got %q", img.ScanStatus) + } + if img.HasSeverityData { + t.Error("has_severity_data: want false when FindingSeverityCounts is nil") + } + if img.FindingsCritical != 0 { + t.Errorf("findings_critical: want 0, got %d", img.FindingsCritical) + } + if img.FindingsHigh != 0 { + t.Errorf("findings_high: want 0, got %d", img.FindingsHigh) + } +} diff --git a/internal/eval.go b/internal/eval.go index c87f172..a90f3be 100644 --- a/internal/eval.go +++ b/internal/eval.go @@ -129,7 +129,7 @@ func (pe *PolicyEvaluator) EvalImage(ctx context.Context, image ImageContext, po if len(digestShort) > 19 { digestShort = digestShort[:19] } - inventoryID := fmt.Sprintf("aws-ecr-image/%s/%s/%s", image.AccountID, image.RepositoryName, digestShort) + inventoryID := fmt.Sprintf("aws-ecr-image/%s/%s/%s/%s", image.AccountID, image.Region, image.RepositoryName, image.ImageDigest) labels := MergeMaps(extraLabels, imageBaseLabels(), map[string]string{ "repository_arn": image.RepositoryArn, @@ -226,7 +226,7 @@ func ecrActors(pluginTitle string) []*proto.OriginActor { Type: "tool", Links: []*proto.Link{ { - Href: "https://github.com/container-solutions/plugin-aws-ecr", + Href: "https://github.com/compliance-framework/plugin-aws-ecr", Rel: StringAddressed("reference"), Text: StringAddressed("The Continuous Compliance Framework AWS ECR Plugin"), },