diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 89adab4..398cb77 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -3,46 +3,55 @@ name: build-and-upload on: workflow_call: -permissions: - packages: write - contents: write - jobs: build-and-upload: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: persist-credentials: false - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + fetch-depth: 0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: go.mod - cache: false - - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 with: - version: '~> v2' + version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: go install github.com/compliance-framework/gooci@v0.0.7 - - run: gooci login ghcr.io --username "${{ github.actor }}" --password "${{ secrets.GITHUB_TOKEN }}" - - id: tag + - id: oci-tag shell: bash run: | + # Build the OCI tag from $GITHUB_REF_NAME, enforcing OCI tag constraints. raw="${GITHUB_REF_NAME}" clean="$(printf '%s' "$raw" | tr -c '[:alnum:]._-' '-')" - if [[ -z "$clean" ]]; then - clean="v" - elif [[ "$clean" == .* || "$clean" == -* ]]; then - clean="v${clean}" - fi + # OCI tags must not start with . or - ; prefix a safe alphanumeric if so. + case "$clean" in + ""|.*|-*) clean="v${clean}" ;; + esac + # OCI tags are max 128 chars. clean="${clean:0:128}" - echo "value=$clean" >> "$GITHUB_OUTPUT" + echo "value=${clean}" >> "$GITHUB_OUTPUT" + + # Prerelease detection MUST use a strict semver prerelease regex, NOT a substring match + # on '-'. Organisation-internal tags often contain '-' without being prereleases. + prerelease=false if [[ "$raw" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "prerelease=false" >> "$GITHUB_OUTPUT" + prerelease=true fi - - run: gooci upload --annotate="org.ccf.plugin.protocol.version=2" "dist/" "ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.value }}" - - if: ${{ steps.tag.outputs.prerelease != 'true' }} - run: gooci upload --annotate="org.ccf.plugin.protocol.version=2" "dist/" "ghcr.io/${{ github.repository }}:latest" + echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT" + - run: go install github.com/compliance-framework/gooci@v0.0.7 + - shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gooci login ghcr.io --username "${GITHUB_ACTOR}" --password "${GITHUB_TOKEN}" + - shell: bash + run: | + repo="${GITHUB_REPOSITORY,,}" + gooci upload --annotate="org.ccf.plugin.protocol.version=2" "dist/" "ghcr.io/${repo}:${{ steps.oci-tag.outputs.value }}" + - if: steps.oci-tag.outputs.prerelease != 'true' + shell: bash + run: | + repo="${GITHUB_REPOSITORY,,}" + gooci upload --annotate="org.ccf.plugin.protocol.version=2" "dist/" "ghcr.io/${repo}:latest" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25d90c9..1896ee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,5 +10,9 @@ permissions: contents: write jobs: - build-and-upload: + release: uses: ./.github/workflows/build-and-upload.yml + secrets: inherit + permissions: + packages: write + contents: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d15e994..1f9c9d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,13 +7,12 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: persist-credentials: false - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: go.mod - cache: false - run: go mod download - run: go mod verify - run: go test ./... diff --git a/README.md b/README.md index 233c6e6..edbb055 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,132 @@ -# AWS ELBv2 CCF Plugin +# AWS Secrets Manager CCF Plugin -This plugin collects read-only AWS Elastic Load Balancing v2 evidence and evaluates CCF Rego policy bundles against one normalized input document per ELBv2 resource. +This repository builds a CCF RunnerV2 plugin for AWS Secrets Manager. It collects read-only Secrets Manager metadata, resource policies, version stages, tags, and CloudTrail event history, then evaluates configured Rego policy bundles against one normalized record per secret ARN. -It implements the RunnerV2 gRPC plugin protocol from `github.com/compliance-framework/agent`. +The plugin never calls `GetSecretValue`. -Component subject templates registered during `Init`: +## Subject -- `aws-elbv2-loadbalancer` -- `aws-elbv2-listener` -- `aws-elbv2-target-group` -- `aws-elbv2-target-health` +The plugin registers one subject template: -Account and region are labels and input context. They are not standalone subjects. +| Name | Type | Identity labels | +| --- | --- | --- | +| `aws-secretsmanager-secret` | component | `account_id`, `region`, `resource_id` | + +Every evidence record also includes labels: `provider=aws`, `type=secretsmanager`, `subject=aws-secretsmanager-secret`, `account_id`, `region`, `resource_id`, `resource_arn`, `resource_type=secret`, and `account_tag_` for account config tags. `resource_id` is the ARN segment after the final `:` and keeps the AWS 6-character suffix. ## Configuration -The CCF agent passes configuration as flat string fields. Structured values are JSON-encoded strings. +The CCF agent passes flat string config. Structured values are JSON strings. -| Key | Default | Description | +| Key | Default | Notes | | --- | --- | --- | -| `accounts` | `[]` | JSON array of `{account_id, regions[], role_arn, external_id, session_name, tags{}}`. Empty means use the configured AWS credential chain. | -| `default_regions` | `[]` | JSON array used when an account omits `regions`; falls back to the AWS SDK default region. | -| `lookback_days` | `90` | Positive integer no greater than `90`, used for CloudTrail LookupEvents. | +| `accounts` | `[]` | JSON array of `{account_id, regions[], role_arn, external_id, session_name, tags{}}`. Empty uses the ambient AWS credential chain. | +| `default_regions` | `[]` | JSON array used when an account omits `regions`; then falls back to the SDK default region. | +| `lookback_days` | `90` | Positive integer up to `90`, used for CloudTrail event history. | | `policy_inputs` | `{}` | JSON object exposed to Rego as `input.policy_inputs`. | | `policy_input` | `{}` | Alias for `policy_inputs`. | -| `policy_labels` | `{}` | JSON string map merged into generated evidence labels. | -| `max_concurrency` | `4` | Positive integer no greater than `32`, used as the worker count for account/region collection. | -| `api_timeout_seconds` | `60` | Positive integer timeout per account/region target. | -| `tag_batch_size` | `20` | Positive integer no greater than `20`, used as the ELBv2 `DescribeTags` resource ARN batch size. | - -Example: - -```json -{ - "accounts": "[{\"account_id\":\"123456789012\",\"regions\":[\"us-east-1\"],\"role_arn\":\"arn:aws:iam::123456789012:role/elbv2-readonly\",\"external_id\":\"ccf\",\"session_name\":\"ccf-elbv2\",\"tags\":{\"environment\":\"prod\"}}]", - "default_regions": "[\"us-east-1\"]", - "lookback_days": "90", - "policy_inputs": "{\"minimum_availability_zones\":2}", - "policy_labels": "{\"team\":\"security\"}" -} -``` - -## Rego Input Schema +| `policy_labels` | `{}` | JSON string map merged into evidence labels. | +| `max_concurrency` | `4` | Positive worker count for account/region targets; `0` is normalized to `1`. | +| `api_timeout_seconds` | `120` | Positive per-target budget covering all paginated Secrets Manager, CloudTrail, and STS calls for one account/region. | -All records share this envelope: +Runtime logs default to `info`. Set `LOG_LEVEL` to `debug`, `info`, `warn`, or `error` to override the level. -- `schema_version`: `v1` -- `source`: `aws-elbv2` -- `account`: `{account_id, role_arn, tags}` -- `region`: `{name}` -- `resource`: `{id, arn, type}` -- `config`: resource-specific fields listed below -- `dynamic`: dynamic evidence enrichment; empty for non-loadbalancer records -- `tags`: ELBv2 tags for load balancer, listener, and target-group records; empty for target-health records -- `collection`: collection metadata, raw payload hash, errors, and optional lookback window -- `policy_inputs`: parsed policy input object +## Rego Input -### `loadbalancer` +Each secret is marshaled with `resource.type = "secret"`: ```json { "schema_version": "v1", - "source": "aws-elbv2", + "source": "aws-secretsmanager", "account": {"account_id": "123456789012", "role_arn": "", "tags": {"environment": "prod"}}, "region": {"name": "us-east-1"}, "resource": { - "id": "app/my-alb/abc123", - "arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc123", - "type": "loadbalancer" + "id": "MyApp/db/credentials-AbCdEf", + "arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:MyApp/db/credentials-AbCdEf", + "type": "secret" }, "config": { - "load_balancer_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc123", - "dns_name": "my-alb-123.us-east-1.elb.amazonaws.com", - "scheme": "internet-facing", - "type": "application", - "state": "active", - "availability_zones": ["us-east-1a", "us-east-1b"] + "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:MyApp/db/credentials-AbCdEf", + "name_hash": "sha256:...", + "kms_key_id": "aws/secretsmanager", + "rotation_enabled": true, + "rotation_lambda_arn": "arn:aws:lambda:us-east-1:123456789012:function:rotate-db", + "rotation_rules": {"automatically_after_days": 30, "schedule_expression": "", "duration": ""}, + "last_rotated_date": "2026-04-12T03:00:00Z", + "last_changed_date": "2026-04-12T03:00:00Z", + "last_accessed_date": "2026-05-26T14:22:11Z", + "deleted_date": "", + "recovery_window_days": 0, + "owning_service": "", + "replication_status": [ + {"region": "us-west-2", "status": "InSync", "last_accessed_date": "2026-05-26T14:22:11Z", "status_message": ""} + ], + "description_hash": "sha256:...", + "resource_policy": { + "hash": "sha256:...", + "document": {"Version": "2012-10-17", "Statement": []}, + "principals": [ + {"principal": "arn:aws:iam::123456789012:role/app-reader", "action": ["secretsmanager:GetSecretValue"], "condition": null, "effect": "Allow"} + ] + }, + "resource_policy_present": true, + "versions": [ + {"version_id": "abc-123", "created_date": "2026-04-12T03:00:00Z", "kms_key_ids": ["arn:aws:kms:us-east-1:123456789012:key/..."], "stages": ["AWSCURRENT"]}, + {"version_id": "def-456", "created_date": "2026-03-13T03:00:00Z", "kms_key_ids": ["arn:aws:kms:us-east-1:123456789012:key/..."], "stages": ["AWSPREVIOUS"]} + ], + "deprecated_version_count": 0 }, "dynamic": { "cloudtrail_events": [ - {"event_name": "ModifyListener", "event_time": "2026-04-01T10:00:00Z", "user_identity_arn": "arn:aws:iam::123456789012:role/admin"} - ] + {"event_name": "RotateSecret", "event_time": "2026-04-12T03:00:00Z", "user_identity_arn": "arn:aws:iam::123456789012:role/rotation", "aws_region": "us-east-1", "event_id": "evt-1", "resources": ["MyApp/db/credentials-AbCdEf"]} + ], + "iam_credential_removal_events": [] }, - "tags": {"owner": "platform-team"}, + "tags": {"Owner": "platform-team", "Environment": "prod", "DataClassification": "confidential"}, "collection": { - "collected_at": "2026-05-14T12:00:00Z", - "collector_version": "aws-elbv2", + "collected_at": "2026-05-28T12:00:00Z", + "collector_version": "aws-secretsmanager", "collection_type": "config_dynamic", - "lookback_window": {"start": "2026-02-13T12:00:00Z", "end": "2026-05-14T12:00:00Z"}, - "raw_payload_hashes": {"primary": "sha256:..."}, + "lookback_window": {"start": "2026-02-27T12:00:00Z", "end": "2026-05-28T12:00:00Z"}, + "raw_payload_hashes": {"describe": "sha256:...", "policy": "sha256:...", "versions": "sha256:..."}, "errors": [] }, - "policy_inputs": {"minimum_availability_zones": 2} + "policy_inputs": {} } ``` -### `listener` +When AWS omits `KmsKeyId` for the AWS-managed default key, `config.kms_key_id` is emitted as the sentinel string `aws/secretsmanager`. Date fields are always strings and are `""` when absent. When no resource policy exists, `resource_policy` is `{"hash":"","document":null,"principals":[]}` and `resource_policy_present` is `false`. -```json -{ - "schema_version": "v1", - "source": "aws-elbv2", - "account": {"account_id": "123456789012", "role_arn": "", "tags": {"environment": "prod"}}, - "region": {"name": "us-east-1"}, - "resource": { - "id": "listener/app/my-alb/abc123/def456", - "arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc123/def456", - "type": "listener" - }, - "config": { - "listener_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc123/def456", - "load_balancer_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc123", - "protocol": "HTTPS", - "port": 443, - "ssl_policy": "ELBSecurityPolicy-TLS13-1-2-2021-06", - "certificate_arn": "arn:aws:acm:us-east-1:123456789012:certificate/cert1" - }, - "dynamic": {}, - "tags": {"tls": "public"}, - "collection": { - "collected_at": "2026-05-14T12:00:00Z", - "collector_version": "aws-elbv2", - "collection_type": "config", - "raw_payload_hashes": {"primary": "sha256:..."}, - "errors": [] - }, - "policy_inputs": {"minimum_availability_zones": 2} -} -``` +`recovery_window_days` defaults to `0` and is populated from a matched CloudTrail `DeleteSecret` event's `requestParameters.recoveryWindowInDays` when that event is available. Secrets Manager does not expose the original recovery-window setting directly on `DescribeSecret`. -### `target-group` +## Coverage -```json -{ - "schema_version": "v1", - "source": "aws-elbv2", - "account": {"account_id": "123456789012", "role_arn": "", "tags": {"environment": "prod"}}, - "region": {"name": "us-east-1"}, - "resource": { - "id": "app-tg/ghi789", - "arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app-tg/ghi789", - "type": "target-group" - }, - "config": { - "target_group_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app-tg/ghi789", - "load_balancer_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc123", - "protocol": "HTTP", - "port": 80, - "health_check_protocol": "HTTP", - "health_check_path": "/healthz", - "healthy_threshold_count": 3, - "unhealthy_threshold_count": 2 - }, - "dynamic": {}, - "tags": {"service": "orders"}, - "collection": { - "collected_at": "2026-05-14T12:00:00Z", - "collector_version": "aws-elbv2", - "collection_type": "config", - "raw_payload_hashes": {"primary": "sha256:..."}, - "errors": [] - }, - "policy_inputs": {"minimum_availability_zones": 2} -} -``` +CONFIG evidence supports rotation, vendor credential, confidentiality, and privacy policy bundles through `DescribeSecret`, `GetResourcePolicy`, `ListSecretVersionIds`, and tags from `DescribeSecret`. Fields include rotation state, rotation rules, KMS key ID string, owning service, replication status, version stages, resource policy principals, and hashed name/description. -### `target-health` +DYNAMIC evidence uses a 90-day default CloudTrail lookback. Secrets Manager events are filtered to `RotateSecret`, `PutSecretValue`, `UpdateSecret`, `UpdateSecretVersionStage`, `DeleteSecret`, `RestoreSecret`, `PutResourcePolicy`, `DeleteResourcePolicy`, `TagResource`, `UntagResource`, `CreateSecret`, and `GetSecretValue`. IAM credential-removal events are filtered to `DeleteUser`, `DeleteAccessKey`, `DetachUserPolicy`, `RemoveUserFromGroup`, `DeleteRole`, `DetachRolePolicy`, and `RemoveRoleFromInstanceProfile`. -```json -{ - "schema_version": "v1", - "source": "aws-elbv2", - "account": {"account_id": "123456789012", "role_arn": "", "tags": {"environment": "prod"}}, - "region": {"name": "us-east-1"}, - "resource": { - "id": "app-tg/ghi789/i-1234567890abcdef0", - "arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app-tg/ghi789", - "type": "target-health" - }, - "config": { - "target_group_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app-tg/ghi789", - "target_id": "i-1234567890abcdef0", - "target_health_state": "healthy" - }, - "dynamic": {}, - "tags": {}, - "collection": { - "collected_at": "2026-05-14T12:00:00Z", - "collector_version": "aws-elbv2", - "collection_type": "config", - "raw_payload_hashes": {"primary": "sha256:..."}, - "errors": [] - }, - "policy_inputs": {"minimum_availability_zones": 2} -} -``` +## CloudTrail Attribution -## Coverage +Secrets Manager events are matched to a secret by ARN, friendly name with suffix, or friendly name without suffix using substring checks against the raw CloudTrail event during collection. This is intentional because many Secrets Manager event types put the identifier in `requestParameters` rather than the structured `Resources` list. + +IAM credential-removal events are account-wide. The plugin parses affected `userName`, `roleName`, `userArn`, and `roleArn` from `requestParameters` and attaches the event only to secrets whose resource-policy principal strings substring-match those identifiers. Unmatched IAM events are dropped. + +Only normalized event fields are emitted; raw CloudTrail payloads are not included in Rego input. -CONFIG evidence includes: +## Error Scoping -- `DescribeLoadBalancers` -- `DescribeListeners` -- `DescribeTargetGroups` -- `DescribeTargetHealth` -- `DescribeTags` for load balancer, listener, and target group tags +Target-level failures, such as `ListSecrets` or CloudTrail lookup failure for an account/region, are stored once under target scope. They are not copied onto every secret. -DYNAMIC evidence includes CloudTrail `LookupEvents` for `elasticloadbalancing.amazonaws.com`, filtered to `CreateListener`, `ModifyListener`, `DeleteListener`, `CreateRule`, `ModifyRule`, and `DeleteRule`, and attached to matching load balancer records as `dynamic.cloudtrail_events`. +Per-secret failures, such as a `GetResourcePolicy` error for one ARN, are attached only to that secret's `collection.errors`. `ResourceNotFoundException` and empty resource policies are valid no-policy states, not errors. -Out of scope: +## Out of Scope -- ACM certificate expiry and renewal status. This plugin does not call `acm.DescribeCertificate`; it only records the listener `certificate_arn`. -- SSL policy cipher inspection. This plugin records only the listener `ssl_policy` string. -- AWS Artifact SOC reports and privacy endpoint inventory. +KMS key policy, key rotation, and grants are collected by `plugin-aws-kms`; this plugin records only the `kms_key_id` string from Secrets Manager. IAM principal resolution belongs to `plugin-aws-iam`; this plugin records policy principal strings and CloudTrail IAM-event ARNs only. Decrypted secret material is never read. ## Development ```sh make build make test -goreleaser build --snapshot --clean ``` diff --git a/collector.go b/collector.go new file mode 100644 index 0000000..b0a135e --- /dev/null +++ b/collector.go @@ -0,0 +1,754 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/cloudtrail" + cttypes "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" + sm "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/compliance-framework/plugin-aws-secretsmanager/internal" + "github.com/hashicorp/go-hclog" +) + +var secretsManagerEventNames = map[string]bool{ + "RotateSecret": true, + "PutSecretValue": true, + "UpdateSecret": true, + "UpdateSecretVersionStage": true, + "DeleteSecret": true, + "RestoreSecret": true, + "PutResourcePolicy": true, + "DeleteResourcePolicy": true, + "TagResource": true, + "UntagResource": true, + "CreateSecret": true, + "GetSecretValue": true, +} + +var iamCredentialRemovalEventNames = map[string]bool{ + "DeleteUser": true, + "DeleteAccessKey": true, + "DetachUserPolicy": true, + "RemoveUserFromGroup": true, + "DeleteRole": true, + "DetachRolePolicy": true, + "RemoveRoleFromInstanceProfile": true, +} + +type SecretsManagerAPI interface { + ListSecrets(context.Context, *sm.ListSecretsInput, ...func(*sm.Options)) (*sm.ListSecretsOutput, error) + DescribeSecret(context.Context, *sm.DescribeSecretInput, ...func(*sm.Options)) (*sm.DescribeSecretOutput, error) + GetResourcePolicy(context.Context, *sm.GetResourcePolicyInput, ...func(*sm.Options)) (*sm.GetResourcePolicyOutput, error) + ListSecretVersionIds(context.Context, *sm.ListSecretVersionIdsInput, ...func(*sm.Options)) (*sm.ListSecretVersionIdsOutput, error) +} + +type CloudTrailAPI interface { + LookupEvents(context.Context, *cloudtrail.LookupEventsInput, ...func(*cloudtrail.Options)) (*cloudtrail.LookupEventsOutput, error) +} + +type STSAPI interface { + GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) +} + +type AWSClientSet struct { + SecretsManager SecretsManagerAPI + CloudTrail CloudTrailAPI + IAMCloudTrail CloudTrailAPI + STS STSAPI +} + +type AWSClientFactory interface { + ResolveTargets(context.Context, *PluginConfig) ([]ResolvedTarget, error) + ClientsForTarget(context.Context, ResolvedTarget) (AWSClientSet, error) +} + +type ResolvedTarget struct { + AccountID string + Region string + RoleARN string + Tags map[string]string + Config aws.Config +} + +type DefaultAWSClientFactory struct{} + +func (DefaultAWSClientFactory) ResolveTargets(ctx context.Context, cfg *PluginConfig) ([]ResolvedTarget, error) { + base, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("load AWS config: %w", err) + } + accounts := cfg.Accounts + if len(accounts) == 0 { + accounts = []AccountConfig{{Regions: cfg.DefaultRegions}} + } + targets := make([]ResolvedTarget, 0) + for _, account := range accounts { + regions := account.Regions + if len(regions) == 0 { + regions = cfg.DefaultRegions + } + if len(regions) == 0 && base.Region != "" { + regions = []string{base.Region} + } + if len(regions) == 0 { + return nil, fmt.Errorf("no AWS region resolved for account %q; set account regions, default_regions, or AWS_REGION", account.AccountID) + } + for _, region := range regions { + awsCfg := base.Copy() + awsCfg.Region = region + if account.RoleARN != "" { + opts := func(o *stscreds.AssumeRoleOptions) { + if account.ExternalID != "" { + o.ExternalID = aws.String(account.ExternalID) + } + if account.SessionName != "" { + o.RoleSessionName = account.SessionName + } + } + awsCfg.Credentials = aws.NewCredentialsCache(stscreds.NewAssumeRoleProvider(sts.NewFromConfig(awsCfg), account.RoleARN, opts)) + } + accountID := account.AccountID + if accountID == "" { + ident, identErr := sts.NewFromConfig(awsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if identErr != nil { + return nil, fmt.Errorf("resolve account id for region %q: %w", region, identErr) + } + accountID = aws.ToString(ident.Account) + } + targets = append(targets, ResolvedTarget{ + AccountID: accountID, + Region: region, + RoleARN: account.RoleARN, + Tags: cloneStringMap(account.Tags), + Config: awsCfg, + }) + } + } + return targets, nil +} + +func (DefaultAWSClientFactory) ClientsForTarget(_ context.Context, target ResolvedTarget) (AWSClientSet, error) { + iamCloudTrailConfig := target.Config.Copy() + iamCloudTrailConfig.Region = "us-east-1" + return AWSClientSet{ + SecretsManager: sm.NewFromConfig(target.Config), + CloudTrail: cloudtrail.NewFromConfig(target.Config), + IAMCloudTrail: cloudtrail.NewFromConfig(iamCloudTrailConfig), + STS: sts.NewFromConfig(target.Config), + }, nil +} + +type Collector struct { + Logger hclog.Logger + Config *PluginConfig + Factory AWSClientFactory +} + +type CollectionResult struct { + Records []*ResourceRecord + Errors map[string][]error + Err error +} + +type secretDraft struct { + arn string + name string + config map[string]interface{} + dynamic map[string]interface{} + tags map[string]string + hashes map[string]string + errors []CollectionError + describe *sm.DescribeSecretOutput + principal []string +} + +type normalizedEvent struct { + EventName string `json:"event_name"` + EventTime string `json:"event_time"` + UserIdentityARN string `json:"user_identity_arn"` + AWSRegion string `json:"aws_region"` + EventID string `json:"event_id"` + Resources []string `json:"resources"` +} + +type collectedEvent struct { + normalized normalizedEvent + raw string + params map[string]interface{} + source string +} + +func (c *Collector) Collect(ctx context.Context) *CollectionResult { + cfg := c.Config + if cfg == nil { + cfg, _ = parsePluginConfig(map[string]string{}) + } + factory := c.Factory + if factory == nil { + factory = DefaultAWSClientFactory{} + } + targets, err := factory.ResolveTargets(ctx, cfg) + if err != nil { + return &CollectionResult{Errors: map[string][]error{"target": {err}}, Err: err} + } + workers := cfg.MaxConcurrency + if workers <= 0 { + workers = 1 + } + if workers > len(targets) && len(targets) > 0 { + workers = len(targets) + } + if workers == 0 { + workers = 1 + } + + result := &CollectionResult{Errors: map[string][]error{}} + var mu sync.Mutex + jobs := make(chan ResolvedTarget) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for target := range jobs { + // APITimeoutSeconds bounds the entire per-target collection across all paginated AWS calls. + targetCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.APITimeoutSeconds)*time.Second) + records, scopedErrors, collectErr := c.collectTarget(targetCtx, factory, target, cfg) + cancel() + mu.Lock() + result.Records = append(result.Records, records...) + for scope, errs := range scopedErrors { + result.Errors[scope] = append(result.Errors[scope], errs...) + } + result.Err = errors.Join(result.Err, collectErr) + mu.Unlock() + } + }() + } + for _, target := range targets { + jobs <- target + } + close(jobs) + wg.Wait() + return result +} + +func (c *Collector) collectTarget(ctx context.Context, factory AWSClientFactory, target ResolvedTarget, cfg *PluginConfig) ([]*ResourceRecord, map[string][]error, error) { + scopedErrors := map[string][]error{} + var accumulated error + clients, err := factory.ClientsForTarget(ctx, target) + if err != nil { + scopedErrors["target"] = append(scopedErrors["target"], err) + return nil, scopedErrors, err + } + collectedAt := time.Now().UTC() + lookbackStart := collectedAt.AddDate(0, 0, -cfg.LookbackDays) + lookback := &Window{Start: lookbackStart.Format(time.RFC3339), End: collectedAt.Format(time.RFC3339)} + + secretARNs, listErr := listSecretARNs(ctx, clients.SecretsManager) + if listErr != nil { + scopedErrors["target"] = append(scopedErrors["target"], listErr) + return nil, scopedErrors, listErr + } + drafts := make(map[string]*secretDraft, len(secretARNs)) + for _, arn := range secretARNs { + draft, perErr := c.collectSecret(ctx, clients.SecretsManager, arn) + drafts[arn] = draft + for _, e := range draft.errors { + scopedErrors[arn] = append(scopedErrors[arn], errors.New(e.Message)) + } + accumulated = errors.Join(accumulated, perErr) + } + + smEvents, smCollected, smErr := lookupEvents(ctx, clients.CloudTrail, "secretsmanager.amazonaws.com", secretsManagerEventNames, lookbackStart, collectedAt) + if smErr != nil { + scopedErrors["target"] = append(scopedErrors["target"], smErr) + accumulated = errors.Join(accumulated, smErr) + } + iamCloudTrail := clients.IAMCloudTrail + if iamCloudTrail == nil { + iamCloudTrail = clients.CloudTrail + } + iamEvents, iamCollected, iamErr := lookupEvents(ctx, iamCloudTrail, "iam.amazonaws.com", iamCredentialRemovalEventNames, lookbackStart, collectedAt) + if iamErr != nil { + scopedErrors["target"] = append(scopedErrors["target"], iamErr) + accumulated = errors.Join(accumulated, iamErr) + } + cloudTrailCollected := smCollected || iamCollected + attachSecretsManagerEvents(drafts, smEvents) + attachIAMCredentialEvents(drafts, iamEvents) + + records := make([]*ResourceRecord, 0, len(drafts)) + for _, arn := range secretARNs { + d := drafts[arn] + records = append(records, newSecretRecord(target, arn, d.config, d.dynamic, d.tags, d.errors, d.hashes, lookback, cfg.PolicyInputs, cloudTrailCollected, collectedAt)) + } + return records, scopedErrors, accumulated +} + +func listSecretARNs(ctx context.Context, client SecretsManagerAPI) ([]string, error) { + var arns []string + var token *string + for { + out, err := client.ListSecrets(ctx, &sm.ListSecretsInput{IncludePlannedDeletion: aws.Bool(true), NextToken: token}) + if err != nil { + return arns, fmt.Errorf("list secrets: %w", err) + } + for _, item := range out.SecretList { + arn := aws.ToString(item.ARN) + if arn != "" { + arns = append(arns, arn) + } + } + if out.NextToken == nil || aws.ToString(out.NextToken) == "" { + break + } + token = out.NextToken + } + return arns, nil +} + +func (c *Collector) collectSecret(ctx context.Context, client SecretsManagerAPI, arn string) (*secretDraft, error) { + d := &secretDraft{ + arn: arn, + config: map[string]interface{}{}, + dynamic: map[string]interface{}{"cloudtrail_events": []normalizedEvent{}, "iam_credential_removal_events": []normalizedEvent{}}, + tags: map[string]string{}, + hashes: map[string]string{"describe": internal.Sha256Hex(nil), "policy": internal.Sha256Hex(nil), "versions": internal.Sha256Hex(nil)}, + } + var accumulated error + describe, err := client.DescribeSecret(ctx, &sm.DescribeSecretInput{SecretId: aws.String(arn)}) + if err != nil { + e := fmt.Errorf("describe secret %s: %w", arn, err) + d.errors = append(d.errors, CollectionError{Scope: arn, Message: e.Error()}) + return d, e + } + d.describe = describe + d.name = aws.ToString(describe.Name) + if payload, marshalErr := json.Marshal(describe); marshalErr == nil { + d.hashes["describe"] = internal.Sha256Hex(payload) + } + for _, tag := range describe.Tags { + k := aws.ToString(tag.Key) + if k != "" { + d.tags[k] = aws.ToString(tag.Value) + } + } + d.config = describeConfig(arn, describe) + + policyOut, policyErr := client.GetResourcePolicy(ctx, &sm.GetResourcePolicyInput{SecretId: aws.String(arn)}) + policyPresent := false + policyInfo := map[string]interface{}{"hash": "", "document": nil, "principals": []map[string]interface{}{}} + if policyErr != nil { + var notFound *smtypes.ResourceNotFoundException + if !errors.As(policyErr, ¬Found) { + e := fmt.Errorf("get resource policy %s: %w", arn, policyErr) + d.errors = append(d.errors, CollectionError{Scope: arn, Message: e.Error()}) + accumulated = errors.Join(accumulated, e) + } + } else { + if payload, marshalErr := json.Marshal(policyOut); marshalErr == nil { + d.hashes["policy"] = internal.Sha256Hex(payload) + } + rawPolicy := aws.ToString(policyOut.ResourcePolicy) + if rawPolicy != "" { + policyPresent = true + policyInfo = parseResourcePolicy(rawPolicy) + d.principal = principalsFromPolicyInfo(policyInfo) + } + } + d.config["resource_policy"] = policyInfo + d.config["resource_policy_present"] = policyPresent + + versions, versionsHash, deprecatedCount, versionErr := listVersions(ctx, client, arn) + d.hashes["versions"] = versionsHash + d.config["versions"] = versions + d.config["deprecated_version_count"] = deprecatedCount + if versionErr != nil { + e := fmt.Errorf("list secret versions %s: %w", arn, versionErr) + d.errors = append(d.errors, CollectionError{Scope: arn, Message: e.Error()}) + accumulated = errors.Join(accumulated, e) + } + return d, accumulated +} + +func describeConfig(arn string, describe *sm.DescribeSecretOutput) map[string]interface{} { + kmsKeyID := aws.ToString(describe.KmsKeyId) + if kmsKeyID == "" { + kmsKeyID = "aws/secretsmanager" + } + rotationRules := map[string]interface{}{ + "automatically_after_days": int64(0), + "schedule_expression": "", + "duration": "", + } + if describe.RotationRules != nil { + rotationRules["automatically_after_days"] = internal.Int64Value(describe.RotationRules.AutomaticallyAfterDays) + rotationRules["schedule_expression"] = aws.ToString(describe.RotationRules.ScheduleExpression) + rotationRules["duration"] = aws.ToString(describe.RotationRules.Duration) + } + replicationStatus := make([]map[string]interface{}, 0, len(describe.ReplicationStatus)) + for _, r := range describe.ReplicationStatus { + replicationStatus = append(replicationStatus, map[string]interface{}{ + "region": aws.ToString(r.Region), + "status": string(r.Status), + "last_accessed_date": internal.FormatTime(r.LastAccessedDate), + "status_message": aws.ToString(r.StatusMessage), + }) + } + return map[string]interface{}{ + "secret_arn": arn, + "name_hash": internal.HashString(aws.ToString(describe.Name)), + "kms_key_id": kmsKeyID, + "rotation_enabled": describe.RotationEnabled != nil && *describe.RotationEnabled, + "rotation_lambda_arn": aws.ToString(describe.RotationLambdaARN), + "rotation_rules": rotationRules, + "last_rotated_date": internal.FormatTime(describe.LastRotatedDate), + "last_changed_date": internal.FormatTime(describe.LastChangedDate), + "last_accessed_date": internal.FormatTime(describe.LastAccessedDate), + "deleted_date": internal.FormatTime(describe.DeletedDate), + "recovery_window_days": 0, + "owning_service": aws.ToString(describe.OwningService), + "replication_status": replicationStatus, + "description_hash": internal.HashString(aws.ToString(describe.Description)), + } +} + +func parseResourcePolicy(raw string) map[string]interface{} { + var doc map[string]interface{} + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + doc = map[string]interface{}{"_parse_error": err.Error()} + } + principals := []map[string]interface{}{} + for _, stmt := range policyStatements(doc["Statement"]) { + effect, _ := stmt["Effect"].(string) + actions := stringList(stmt["Action"]) + condition := stmt["Condition"] + for _, principal := range principalStrings(stmt["Principal"]) { + principals = append(principals, map[string]interface{}{ + "principal": principal, + "action": actions, + "condition": condition, + "effect": effect, + }) + } + } + return map[string]interface{}{ + "hash": internal.HashString(raw), + "document": doc, + "principals": principals, + } +} + +func policyStatements(v interface{}) []map[string]interface{} { + switch t := v.(type) { + case []interface{}: + out := make([]map[string]interface{}, 0, len(t)) + for _, item := range t { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + case map[string]interface{}: + return []map[string]interface{}{t} + default: + return nil + } +} + +func stringList(v interface{}) []string { + switch t := v.(type) { + case string: + return []string{t} + case []interface{}: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return []string{} + } +} + +func principalStrings(v interface{}) []string { + switch t := v.(type) { + case string: + return []string{t} + case []interface{}: + out := []string{} + for _, item := range t { + out = append(out, principalStrings(item)...) + } + return out + case map[string]interface{}: + out := []string{} + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = append(out, principalStrings(t[k])...) + } + return out + default: + return []string{} + } +} + +func principalsFromPolicyInfo(policy map[string]interface{}) []string { + raw, _ := policy["principals"].([]map[string]interface{}) + out := make([]string, 0, len(raw)) + for _, item := range raw { + if p, ok := item["principal"].(string); ok { + out = append(out, p) + } + } + return out +} + +func listVersions(ctx context.Context, client SecretsManagerAPI, arn string) ([]map[string]interface{}, string, int, error) { + var pages []*sm.ListSecretVersionIdsOutput + versions := []map[string]interface{}{} + deprecated := 0 + var token *string + var accumulated error + includeDeprecated := true + for { + out, err := client.ListSecretVersionIds(ctx, &sm.ListSecretVersionIdsInput{SecretId: aws.String(arn), IncludeDeprecated: aws.Bool(includeDeprecated), NextToken: token}) + if err != nil { + accumulated = errors.Join(accumulated, err) + break + } + pages = append(pages, out) + for _, entry := range out.Versions { + stages := append([]string{}, entry.VersionStages...) + if len(stages) == 0 { + deprecated++ + } + versions = append(versions, map[string]interface{}{ + "version_id": aws.ToString(entry.VersionId), + "created_date": internal.FormatTime(entry.CreatedDate), + "kms_key_ids": append([]string{}, entry.KmsKeyIds...), + "stages": stages, + }) + } + if out.NextToken == nil || aws.ToString(out.NextToken) == "" { + break + } + token = out.NextToken + } + payload, _ := json.Marshal(pages) + return versions, internal.Sha256Hex(payload), deprecated, accumulated +} + +func lookupEvents(ctx context.Context, client CloudTrailAPI, source string, allowed map[string]bool, start, end time.Time) ([]collectedEvent, bool, error) { + out := []collectedEvent{} + var token *string + for { + page, err := client.LookupEvents(ctx, &cloudtrail.LookupEventsInput{ + StartTime: aws.Time(start), + EndTime: aws.Time(end), + LookupAttributes: []cttypes.LookupAttribute{{ + AttributeKey: cttypes.LookupAttributeKeyEventSource, + AttributeValue: aws.String(source), + }}, + NextToken: token, + }) + if err != nil { + return out, len(out) > 0, fmt.Errorf("lookup CloudTrail %s: %w", source, err) + } + for _, event := range page.Events { + name := aws.ToString(event.EventName) + if !allowed[name] { + continue + } + raw := aws.ToString(event.CloudTrailEvent) + params, userARN, region := parseCloudTrailRaw(raw) + out = append(out, collectedEvent{ + normalized: normalizedEvent{ + EventName: name, + EventTime: internal.FormatTime(event.EventTime), + UserIdentityARN: userARN, + AWSRegion: region, + EventID: aws.ToString(event.EventId), + Resources: eventResourceNames(event.Resources), + }, + raw: raw, + params: params, + source: source, + }) + } + if page.NextToken == nil || aws.ToString(page.NextToken) == "" { + break + } + token = page.NextToken + } + return out, true, nil +} + +func parseCloudTrailRaw(raw string) (map[string]interface{}, string, string) { + var doc map[string]interface{} + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return map[string]interface{}{}, "", "" + } + params, _ := doc["requestParameters"].(map[string]interface{}) + userARN := "" + if user, ok := doc["userIdentity"].(map[string]interface{}); ok { + userARN, _ = user["arn"].(string) + } + region, _ := doc["awsRegion"].(string) + return params, userARN, region +} + +func eventResourceNames(resources []cttypes.Resource) []string { + out := make([]string, 0, len(resources)) + for _, r := range resources { + if name := aws.ToString(r.ResourceName); name != "" { + out = append(out, name) + } + } + return out +} + +func attachSecretsManagerEvents(drafts map[string]*secretDraft, events []collectedEvent) { + highConfidence := map[string]map[string]bool{} + friendlyNames := map[string]map[string]bool{} + addIdentifier := func(index map[string]map[string]bool, ident, arn string) { + if ident == "" { + return + } + if index[ident] == nil { + index[ident] = map[string]bool{} + } + index[ident][arn] = true + } + for arn, d := range drafts { + addIdentifier(highConfidence, arn, arn) + id := secretIDFromARN(arn) + addIdentifier(highConfidence, id, arn) + if without := secretNameWithoutSuffix(id); without != "" { + addIdentifier(friendlyNames, without, arn) + } + if d.name != "" { + addIdentifier(friendlyNames, d.name, arn) + } + } + seen := map[string]bool{} + for _, event := range events { + // Substring matching against event.Raw is deliberate: many Secrets Manager + // CloudTrail events carry the secret identifier inside requestParameters more + // reliably than the structured Resources list. + matched := map[string]bool{} + for ident, arns := range highConfidence { + if !strings.Contains(event.raw, ident) { + continue + } + for arn := range arns { + matched[arn] = true + } + } + if len(matched) == 0 { + for ident, arns := range friendlyNames { + if !strings.Contains(event.raw, ident) { + continue + } + for arn := range arns { + matched[arn] = true + } + } + } + for arn := range matched { + key := event.normalized.EventID + "\x00" + arn + if seen[key] { + continue + } + seen[key] = true + d := drafts[arn] + d.dynamic["cloudtrail_events"] = appendNormalizedEvent(d.dynamic["cloudtrail_events"], event.normalized) + if event.normalized.EventName == "DeleteSecret" { + // DescribeSecret exposes DeletedDate but not the original recovery window. + // When CloudTrail has the DeleteSecret request, use its request parameter. + if days := recoveryWindowDays(event.params); days > 0 { + d.config["recovery_window_days"] = days + } + } + } + } +} + +func attachIAMCredentialEvents(drafts map[string]*secretDraft, events []collectedEvent) { + for _, event := range events { + affected := affectedIAMIdentifiers(event.params) + if len(affected) == 0 { + continue + } + for _, d := range drafts { + // IAM credential-removal events are account-wide. We attribute them only + // when the affected user/role identifier appears in a resource-policy + // principal string, and otherwise drop the event. + if principalsMatchAffected(d.principal, affected) { + d.dynamic["iam_credential_removal_events"] = appendNormalizedEvent(d.dynamic["iam_credential_removal_events"], event.normalized) + } + } + } +} + +func appendNormalizedEvent(current interface{}, event normalizedEvent) []normalizedEvent { + switch t := current.(type) { + case []normalizedEvent: + return append(t, event) + default: + return []normalizedEvent{event} + } +} + +func recoveryWindowDays(params map[string]interface{}) int { + for _, key := range []string{"recoveryWindowInDays", "RecoveryWindowInDays"} { + switch v := params[key].(type) { + case float64: + return int(v) + case int: + return v + } + } + return 0 +} + +func affectedIAMIdentifiers(params map[string]interface{}) []string { + keys := []string{"userName", "roleName", "userArn", "roleArn"} + out := []string{} + for _, key := range keys { + if v, ok := params[key].(string); ok && v != "" { + out = append(out, v) + } + } + return out +} + +func principalsMatchAffected(principals, affected []string) bool { + for _, principal := range principals { + for _, ident := range affected { + if principal != "" && ident != "" && strings.Contains(principal, ident) { + return true + } + } + } + return false +} diff --git a/collector_test.go b/collector_test.go new file mode 100644 index 0000000..878811f --- /dev/null +++ b/collector_test.go @@ -0,0 +1,475 @@ +package main + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudtrail" + cttypes "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" + sm "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/hashicorp/go-hclog" +) + +type fakeFactory struct { + targets []ResolvedTarget + set AWSClientSet +} + +func (f fakeFactory) ResolveTargets(context.Context, *PluginConfig) ([]ResolvedTarget, error) { + return f.targets, nil +} + +func (f fakeFactory) ClientsForTarget(context.Context, ResolvedTarget) (AWSClientSet, error) { + return f.set, nil +} + +func TestDefaultAWSClientFactoryResolveTargetsRequiresRegion(t *testing.T) { + dir := t.TempDir() + configFile := dir + "/config" + credentialsFile := dir + "/credentials" + if err := os.WriteFile(configFile, []byte(""), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.WriteFile(credentialsFile, []byte(""), 0600); err != nil { + t.Fatalf("write credentials: %v", err) + } + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_DEFAULT_REGION", "") + t.Setenv("AWS_CONFIG_FILE", configFile) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credentialsFile) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + _, err := (DefaultAWSClientFactory{}).ResolveTargets(context.Background(), &PluginConfig{ + Accounts: []AccountConfig{{AccountID: "123456789012"}}, + }) + if err == nil { + t.Fatalf("expected missing region error") + } + if !strings.Contains(err.Error(), "no AWS region resolved for account \"123456789012\"") { + t.Fatalf("error = %v", err) + } +} + +type fakeSTS struct{} + +func (fakeSTS) GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{Account: aws.String("123456789012")}, nil +} + +type fakeSM struct { + mu sync.Mutex + listPages []*sm.ListSecretsOutput + listErr error + describes map[string]*sm.DescribeSecretOutput + policies map[string]*sm.GetResourcePolicyOutput + policyErrs map[string]error + versionPages map[string][]*sm.ListSecretVersionIdsOutput + describeCalls map[string]int + policyCalls map[string]int + versionCalls map[string]int + listSecretCalls int + listInputs []*sm.ListSecretsInput +} + +func (f *fakeSM) ListSecrets(_ context.Context, in *sm.ListSecretsInput, _ ...func(*sm.Options)) (*sm.ListSecretsOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.listSecretCalls++ + f.listInputs = append(f.listInputs, in) + if f.listErr != nil { + return nil, f.listErr + } + idx := f.listSecretCalls - 1 + if idx >= len(f.listPages) { + return &sm.ListSecretsOutput{}, nil + } + return f.listPages[idx], nil +} + +func (f *fakeSM) DescribeSecret(_ context.Context, in *sm.DescribeSecretInput, _ ...func(*sm.Options)) (*sm.DescribeSecretOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + arn := aws.ToString(in.SecretId) + f.describeCalls[arn]++ + return f.describes[arn], nil +} + +func (f *fakeSM) GetResourcePolicy(_ context.Context, in *sm.GetResourcePolicyInput, _ ...func(*sm.Options)) (*sm.GetResourcePolicyOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + arn := aws.ToString(in.SecretId) + f.policyCalls[arn]++ + if err := f.policyErrs[arn]; err != nil { + return nil, err + } + return f.policies[arn], nil +} + +func (f *fakeSM) ListSecretVersionIds(_ context.Context, in *sm.ListSecretVersionIdsInput, _ ...func(*sm.Options)) (*sm.ListSecretVersionIdsOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + arn := aws.ToString(in.SecretId) + f.versionCalls[arn]++ + pages := f.versionPages[arn] + idx := f.versionCalls[arn] - 1 + if idx >= len(pages) { + return &sm.ListSecretVersionIdsOutput{}, nil + } + return pages[idx], nil +} + +type fakeCT struct { + events map[string][]cttypes.Event + calls []string +} + +func (f *fakeCT) LookupEvents(_ context.Context, in *cloudtrail.LookupEventsInput, _ ...func(*cloudtrail.Options)) (*cloudtrail.LookupEventsOutput, error) { + source := aws.ToString(in.LookupAttributes[0].AttributeValue) + f.calls = append(f.calls, source) + return &cloudtrail.LookupEventsOutput{Events: f.events[source]}, nil +} + +func TestCollectorCollectsSecretShapeAndCloudTrail(t *testing.T) { + arn1 := "arn:aws:secretsmanager:us-east-1:123456789012:secret:MyApp/db-AbCdEf" + arn2 := "arn:aws:secretsmanager:us-east-1:123456789012:secret:Vendor/key-GhIjKl" + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + smFake := &fakeSM{ + listPages: []*sm.ListSecretsOutput{ + {SecretList: []smtypes.SecretListEntry{{ARN: aws.String(arn1)}}, NextToken: aws.String("next")}, + {SecretList: []smtypes.SecretListEntry{{ARN: aws.String(arn2)}}}, + }, + describes: map[string]*sm.DescribeSecretOutput{ + arn1: { + ARN: aws.String(arn1), Name: aws.String("MyApp/db-AbCdEf"), Description: aws.String("db secret"), + KmsKeyId: aws.String(""), RotationEnabled: aws.Bool(false), LastChangedDate: aws.Time(now), + Tags: []smtypes.Tag{{Key: aws.String("DataClassification"), Value: aws.String("confidential")}}, + ReplicationStatus: []smtypes.ReplicationStatusType{{Region: aws.String("us-west-2"), Status: smtypes.StatusTypeInSync, LastAccessedDate: aws.Time(now)}}, + }, + arn2: {ARN: aws.String(arn2), Name: aws.String("Vendor/key-GhIjKl"), KmsKeyId: aws.String("kms-key")}, + }, + policies: map[string]*sm.GetResourcePolicyOutput{ + arn1: {ResourcePolicy: aws.String(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:user/app-reader"},"Action":["secretsmanager:GetSecretValue"]}}`)}, + arn2: {ResourcePolicy: aws.String("")}, + }, + policyErrs: map[string]error{ + arn2: &smtypes.ResourceNotFoundException{Message: aws.String("none")}, + }, + versionPages: map[string][]*sm.ListSecretVersionIdsOutput{ + arn1: { + {Versions: []smtypes.SecretVersionsListEntry{{VersionId: aws.String("v1"), CreatedDate: aws.Time(now), VersionStages: []string{"AWSCURRENT"}, KmsKeyIds: []string{"k1"}}}, NextToken: aws.String("next")}, + {Versions: []smtypes.SecretVersionsListEntry{{VersionId: aws.String("v0"), CreatedDate: aws.Time(now.Add(-time.Hour)), VersionStages: []string{}, KmsKeyIds: []string{"k0"}}}}, + }, + arn2: {{Versions: []smtypes.SecretVersionsListEntry{{VersionId: aws.String("v2"), VersionStages: []string{"AWSPREVIOUS"}}}}}, + }, + describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}, + } + ctFake := &fakeCT{events: map[string][]cttypes.Event{ + "secretsmanager.amazonaws.com": {{ + EventName: aws.String("RotateSecret"), EventId: aws.String("evt-1"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"secretsmanager.amazonaws.com","eventName":"RotateSecret","awsRegion":"us-east-1","userIdentity":{"arn":"arn:aws:iam::123456789012:role/rotation"},"requestParameters":{"secretId":"` + arn1 + `"}}`), + }}, + "iam.amazonaws.com": {{ + EventName: aws.String("DeleteAccessKey"), EventId: aws.String("evt-2"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"iam.amazonaws.com","eventName":"DeleteAccessKey","awsRegion":"us-east-1","userIdentity":{"arn":"arn:aws:iam::123456789012:role/admin"},"requestParameters":{"userName":"app-reader"}}`), + }}, + }} + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 1, APITimeoutSeconds: 30, PolicyInputs: map[string]interface{}{}} + collector := &Collector{Logger: hclog.NewNullLogger(), Config: cfg, Factory: fakeFactory{ + targets: []ResolvedTarget{{AccountID: "123456789012", Region: "us-east-1"}}, + set: AWSClientSet{SecretsManager: smFake, CloudTrail: ctFake, STS: fakeSTS{}}, + }} + result := collector.Collect(context.Background()) + if result.Err != nil { + t.Fatalf("collect: %v", result.Err) + } + if len(result.Records) != 2 { + t.Fatalf("records = %d", len(result.Records)) + } + if smFake.listSecretCalls != 2 || smFake.describeCalls[arn1] != 1 || smFake.policyCalls[arn1] != 1 || smFake.versionCalls[arn1] != 2 { + t.Fatalf("unexpected call counts: list=%d describe=%d policy=%d versions=%d", smFake.listSecretCalls, smFake.describeCalls[arn1], smFake.policyCalls[arn1], smFake.versionCalls[arn1]) + } + for i, in := range smFake.listInputs { + if in.IncludePlannedDeletion == nil || !*in.IncludePlannedDeletion { + t.Fatalf("list page %d did not include planned deletion", i) + } + } + rec := result.Records[0] + if rec.Input.Config["kms_key_id"] != "aws/secretsmanager" { + t.Fatalf("default kms sentinel missing: %v", rec.Input.Config["kms_key_id"]) + } + if rec.Input.Config["deprecated_version_count"].(int) != 1 { + t.Fatalf("deprecated count = %v", rec.Input.Config["deprecated_version_count"]) + } + if got := rec.Input.Config["replication_status"].([]map[string]interface{})[0]["region"]; got != "us-west-2" { + t.Fatalf("replication region = %v", got) + } + if len(rec.Input.Dynamic["cloudtrail_events"].([]normalizedEvent)) != 1 { + t.Fatalf("secretsmanager event not attached") + } + if len(rec.Input.Dynamic["iam_credential_removal_events"].([]normalizedEvent)) != 1 { + t.Fatalf("iam event not attached") + } + if rec.Labels["resource_id"] != "MyApp/db-AbCdEf" { + t.Fatalf("resource id stripped suffix: %s", rec.Labels["resource_id"]) + } +} + +func TestCollectorErrorScoping(t *testing.T) { + arn := "arn:aws:secretsmanager:us-east-1:123456789012:secret:One-AbCdEf" + t.Run("target list failure once", func(t *testing.T) { + smFake := &fakeSM{listErr: errors.New("boom"), describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}} + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 1, APITimeoutSeconds: 30, PolicyInputs: map[string]interface{}{}} + result := (&Collector{Config: cfg, Factory: fakeFactory{targets: []ResolvedTarget{{AccountID: "123", Region: "us-east-1"}}, set: AWSClientSet{SecretsManager: smFake, CloudTrail: &fakeCT{}, STS: fakeSTS{}}}}).Collect(context.Background()) + if len(result.Errors["target"]) != 1 { + t.Fatalf("target errors = %d", len(result.Errors["target"])) + } + if len(result.Errors) != 1 { + t.Fatalf("unexpected scoped errors: %#v", result.Errors) + } + }) + t.Run("per secret policy failure only on arn", func(t *testing.T) { + smFake := &fakeSM{ + listPages: []*sm.ListSecretsOutput{{SecretList: []smtypes.SecretListEntry{{ARN: aws.String(arn)}}}}, + describes: map[string]*sm.DescribeSecretOutput{arn: {ARN: aws.String(arn), Name: aws.String("One-AbCdEf")}}, + policyErrs: map[string]error{arn: errors.New("policy denied")}, + policies: map[string]*sm.GetResourcePolicyOutput{}, + versionPages: map[string][]*sm.ListSecretVersionIdsOutput{arn: {{}}}, + describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}, + } + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 1, APITimeoutSeconds: 30, PolicyInputs: map[string]interface{}{}} + result := (&Collector{Config: cfg, Factory: fakeFactory{targets: []ResolvedTarget{{AccountID: "123", Region: "us-east-1"}}, set: AWSClientSet{SecretsManager: smFake, CloudTrail: &fakeCT{}, STS: fakeSTS{}}}}).Collect(context.Background()) + if len(result.Errors["target"]) != 0 { + t.Fatalf("target errors should be empty: %#v", result.Errors["target"]) + } + if len(result.Errors[arn]) != 1 || !strings.Contains(result.Errors[arn][0].Error(), "policy denied") { + t.Fatalf("arn errors = %#v", result.Errors[arn]) + } + }) +} + +func TestCollectorIncludesPlannedDeletionAndRecoveryWindow(t *testing.T) { + arn := "arn:aws:secretsmanager:us-east-1:123456789012:secret:PendingDelete-AbCdEf" + deletedAt := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + smFake := &fakeSM{ + listPages: []*sm.ListSecretsOutput{ + {SecretList: []smtypes.SecretListEntry{{ARN: aws.String(arn)}}, NextToken: aws.String("next")}, + {SecretList: []smtypes.SecretListEntry{}}, + }, + describes: map[string]*sm.DescribeSecretOutput{ + arn: {ARN: aws.String(arn), Name: aws.String("PendingDelete-AbCdEf"), DeletedDate: aws.Time(deletedAt)}, + }, + policies: map[string]*sm.GetResourcePolicyOutput{arn: {ResourcePolicy: aws.String("")}}, + policyErrs: map[string]error{}, + versionPages: map[string][]*sm.ListSecretVersionIdsOutput{arn: {{}}}, + describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}, + } + regionalCT := &fakeCT{events: map[string][]cttypes.Event{ + "secretsmanager.amazonaws.com": {{ + EventName: aws.String("DeleteSecret"), EventId: aws.String("delete-1"), EventTime: aws.Time(deletedAt), + CloudTrailEvent: aws.String(`{"eventSource":"secretsmanager.amazonaws.com","eventName":"DeleteSecret","awsRegion":"us-east-1","userIdentity":{"arn":"arn:aws:iam::123456789012:role/admin"},"requestParameters":{"secretId":"` + arn + `","recoveryWindowInDays":14}}`), + }}, + }} + globalCT := &fakeCT{events: map[string][]cttypes.Event{}} + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 1, APITimeoutSeconds: 30, PolicyInputs: map[string]interface{}{}} + result := (&Collector{Config: cfg, Factory: fakeFactory{ + targets: []ResolvedTarget{{AccountID: "123456789012", Region: "us-east-1"}}, + set: AWSClientSet{SecretsManager: smFake, CloudTrail: regionalCT, IAMCloudTrail: globalCT, STS: fakeSTS{}}, + }}).Collect(context.Background()) + if result.Err != nil { + t.Fatalf("collect: %v", result.Err) + } + if len(result.Records) != 1 { + t.Fatalf("records = %d", len(result.Records)) + } + for i, in := range smFake.listInputs { + if in.IncludePlannedDeletion == nil || !*in.IncludePlannedDeletion { + t.Fatalf("list page %d did not include planned deletion", i) + } + } + rec := result.Records[0] + if rec.Input.Config["deleted_date"] == "" { + t.Fatalf("deleted_date not exposed") + } + if got := rec.Input.Config["recovery_window_days"]; got != 14 { + t.Fatalf("recovery_window_days = %v", got) + } +} + +func TestCollectorUsesGlobalCloudTrailForIAMEvents(t *testing.T) { + arn := "arn:aws:secretsmanager:us-west-2:123456789012:secret:App/db-AbCdEf" + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + smFake := &fakeSM{ + listPages: []*sm.ListSecretsOutput{{SecretList: []smtypes.SecretListEntry{{ARN: aws.String(arn)}}}}, + describes: map[string]*sm.DescribeSecretOutput{ + arn: {ARN: aws.String(arn), Name: aws.String("App/db-AbCdEf")}, + }, + policies: map[string]*sm.GetResourcePolicyOutput{ + arn: {ResourcePolicy: aws.String(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:user/app-reader"},"Action":["secretsmanager:GetSecretValue"]}}`)}, + }, + policyErrs: map[string]error{}, + versionPages: map[string][]*sm.ListSecretVersionIdsOutput{arn: {{}}}, + describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}, + } + regionalCT := &fakeCT{events: map[string][]cttypes.Event{ + "secretsmanager.amazonaws.com": {{ + EventName: aws.String("RotateSecret"), EventId: aws.String("rotate-1"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"secretsmanager.amazonaws.com","eventName":"RotateSecret","awsRegion":"us-west-2","userIdentity":{"arn":"arn:aws:iam::123456789012:role/rotation"},"requestParameters":{"secretId":"` + arn + `"}}`), + }}, + }} + globalCT := &fakeCT{events: map[string][]cttypes.Event{ + "iam.amazonaws.com": {{ + EventName: aws.String("DeleteAccessKey"), EventId: aws.String("iam-1"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"iam.amazonaws.com","eventName":"DeleteAccessKey","awsRegion":"us-east-1","userIdentity":{"arn":"arn:aws:iam::123456789012:role/admin"},"requestParameters":{"userName":"app-reader"}}`), + }}, + }} + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 1, APITimeoutSeconds: 30, PolicyInputs: map[string]interface{}{}} + result := (&Collector{Config: cfg, Factory: fakeFactory{ + targets: []ResolvedTarget{{AccountID: "123456789012", Region: "us-west-2"}}, + set: AWSClientSet{SecretsManager: smFake, CloudTrail: regionalCT, IAMCloudTrail: globalCT, STS: fakeSTS{}}, + }}).Collect(context.Background()) + if result.Err != nil { + t.Fatalf("collect: %v", result.Err) + } + if len(regionalCT.calls) != 1 || regionalCT.calls[0] != "secretsmanager.amazonaws.com" { + t.Fatalf("regional CloudTrail calls = %#v", regionalCT.calls) + } + if len(globalCT.calls) != 1 || globalCT.calls[0] != "iam.amazonaws.com" { + t.Fatalf("global CloudTrail calls = %#v", globalCT.calls) + } + if len(result.Records) != 1 { + t.Fatalf("records = %d", len(result.Records)) + } + if len(result.Records[0].Input.Dynamic["cloudtrail_events"].([]normalizedEvent)) != 1 { + t.Fatalf("regional Secrets Manager event not attached") + } + if len(result.Records[0].Input.Dynamic["iam_credential_removal_events"].([]normalizedEvent)) != 1 { + t.Fatalf("global IAM event not attached") + } +} + +func TestCollectorAttachesFriendlyNameEventsToDuplicateNames(t *testing.T) { + arn1 := "arn:aws:secretsmanager:us-east-1:123456789012:secret:Shared/name-AbCdEf" + arn2 := "arn:aws:secretsmanager:us-east-1:123456789012:secret:Shared/name-ZyXwVu" + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + smFake := &fakeSM{ + listPages: []*sm.ListSecretsOutput{{SecretList: []smtypes.SecretListEntry{ + {ARN: aws.String(arn1)}, + {ARN: aws.String(arn2)}, + }}}, + describes: map[string]*sm.DescribeSecretOutput{ + arn1: {ARN: aws.String(arn1), Name: aws.String("Shared/name-AbCdEf")}, + arn2: {ARN: aws.String(arn2), Name: aws.String("Shared/name-ZyXwVu")}, + }, + policies: map[string]*sm.GetResourcePolicyOutput{ + arn1: {ResourcePolicy: aws.String("")}, + arn2: {ResourcePolicy: aws.String("")}, + }, + policyErrs: map[string]error{}, + versionPages: map[string][]*sm.ListSecretVersionIdsOutput{ + arn1: {{}}, + arn2: {{}}, + }, + describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}, + } + regionalCT := &fakeCT{events: map[string][]cttypes.Event{ + "secretsmanager.amazonaws.com": {{ + EventName: aws.String("UpdateSecret"), EventId: aws.String("shared-1"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"secretsmanager.amazonaws.com","eventName":"UpdateSecret","awsRegion":"us-east-1","userIdentity":{"arn":"arn:aws:iam::123456789012:role/admin"},"requestParameters":{"secretId":"Shared/name"}}`), + }}, + }} + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 1, APITimeoutSeconds: 30, PolicyInputs: map[string]interface{}{}} + result := (&Collector{Config: cfg, Factory: fakeFactory{ + targets: []ResolvedTarget{{AccountID: "123456789012", Region: "us-east-1"}}, + set: AWSClientSet{SecretsManager: smFake, CloudTrail: regionalCT, IAMCloudTrail: &fakeCT{}, STS: fakeSTS{}}, + }}).Collect(context.Background()) + if result.Err != nil { + t.Fatalf("collect: %v", result.Err) + } + if len(result.Records) != 2 { + t.Fatalf("records = %d", len(result.Records)) + } + for _, rec := range result.Records { + events := rec.Input.Dynamic["cloudtrail_events"].([]normalizedEvent) + if len(events) != 1 { + t.Fatalf("%s cloudtrail events = %d", rec.Labels["resource_arn"], len(events)) + } + if events[0].EventID != "shared-1" { + t.Fatalf("%s event id = %s", rec.Labels["resource_arn"], events[0].EventID) + } + } +} + +func TestPrincipalsMatchAffectedIsOneWay(t *testing.T) { + if !principalsMatchAffected([]string{"arn:aws:iam::123456789012:user/app-reader"}, []string{"app-reader"}) { + t.Fatalf("expected username to match containing principal ARN") + } + if principalsMatchAffected([]string{"ad"}, []string{"arn:aws:iam::123456789012:user/admin"}) { + t.Fatalf("reverse substring match should not attach unrelated principal") + } +} + +func TestAttachIAMCredentialEventsMatchesRoleNameOnlyWhenPrincipalContainsIt(t *testing.T) { + matchedARN := "arn:aws:secretsmanager:us-east-1:123456789012:secret:Matched-AbCdEf" + unmatchedARN := "arn:aws:secretsmanager:us-east-1:123456789012:secret:Unmatched-GhIjKl" + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + drafts := map[string]*secretDraft{ + matchedARN: { + dynamic: map[string]interface{}{"iam_credential_removal_events": []normalizedEvent{}}, + principal: []string{"arn:aws:iam::123456789012:role/app-rotator"}, + }, + unmatchedARN: { + dynamic: map[string]interface{}{"iam_credential_removal_events": []normalizedEvent{}}, + principal: []string{"arn:aws:iam::123456789012:role/other-service"}, + }, + } + ctFake := &fakeCT{events: map[string][]cttypes.Event{ + "iam.amazonaws.com": { + { + EventName: aws.String("DeleteRole"), EventId: aws.String("role-1"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"iam.amazonaws.com","eventName":"DeleteRole","awsRegion":"us-east-1","requestParameters":{"roleName":"app-rotator"}}`), + }, + { + EventName: aws.String("DetachRolePolicy"), EventId: aws.String("role-2"), EventTime: aws.Time(now), + CloudTrailEvent: aws.String(`{"eventSource":"iam.amazonaws.com","eventName":"DetachRolePolicy","awsRegion":"us-east-1","requestParameters":{"roleName":"unreferenced-role"}}`), + }, + }, + }} + events, _, err := lookupEvents(context.Background(), ctFake, "iam.amazonaws.com", iamCredentialRemovalEventNames, now.Add(-time.Hour), now) + if err != nil { + t.Fatalf("lookup events: %v", err) + } + + attachIAMCredentialEvents(drafts, events) + + matchedEvents := drafts[matchedARN].dynamic["iam_credential_removal_events"].([]normalizedEvent) + if len(matchedEvents) != 1 || matchedEvents[0].EventID != "role-1" { + t.Fatalf("matched role events = %#v", matchedEvents) + } + unmatchedEvents := drafts[unmatchedARN].dynamic["iam_credential_removal_events"].([]normalizedEvent) + if len(unmatchedEvents) != 0 { + t.Fatalf("unmatched role events = %#v", unmatchedEvents) + } +} + +func TestCollectorMaxConcurrencyZeroDoesNotHang(t *testing.T) { + cfg := &PluginConfig{LookbackDays: 90, MaxConcurrency: 0, APITimeoutSeconds: 1, PolicyInputs: map[string]interface{}{}} + smFake := &fakeSM{listPages: []*sm.ListSecretsOutput{{}}, describeCalls: map[string]int{}, policyCalls: map[string]int{}, versionCalls: map[string]int{}} + done := make(chan struct{}) + go func() { + (&Collector{Config: cfg, Factory: fakeFactory{targets: []ResolvedTarget{{AccountID: "123", Region: "us-east-1"}}, set: AWSClientSet{SecretsManager: smFake, CloudTrail: &fakeCT{}, STS: fakeSTS{}}}}).Collect(context.Background()) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("collector hung with MaxConcurrency=0") + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..eb3b218 --- /dev/null +++ b/config.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" +) + +const ( + defaultLookbackDays = 90 // default window for cloudtrail.LookupEvents + maxLookbackDays = 90 // hard cap; CloudTrail event-history supports up to 90 days +) + +type PluginConfig struct { + Accounts []AccountConfig + DefaultRegions []string + LookbackDays int + PolicyInputs map[string]interface{} + PolicyLabels map[string]string + MaxConcurrency int + // APITimeoutSeconds bounds the entire per-target collection across all paginated AWS calls. + APITimeoutSeconds int +} + +type AccountConfig struct { + AccountID string `json:"account_id"` + Regions []string `json:"regions"` + RoleARN string `json:"role_arn"` + ExternalID string `json:"external_id"` + SessionName string `json:"session_name"` + Tags map[string]string `json:"tags"` +} + +func parsePluginConfig(raw map[string]string) (*PluginConfig, error) { + cfg := &PluginConfig{ + Accounts: []AccountConfig{}, + DefaultRegions: []string{}, + LookbackDays: defaultLookbackDays, + PolicyInputs: map[string]interface{}{}, + PolicyLabels: map[string]string{}, + MaxConcurrency: 4, + APITimeoutSeconds: 120, + } + if raw == nil { + return cfg, nil + } + if v, ok := raw["accounts"]; ok && v != "" { + if err := json.Unmarshal([]byte(v), &cfg.Accounts); err != nil { + return nil, fmt.Errorf("parse accounts: %w", err) + } + } + if v, ok := raw["default_regions"]; ok && v != "" { + if err := json.Unmarshal([]byte(v), &cfg.DefaultRegions); err != nil { + return nil, fmt.Errorf("parse default_regions: %w", err) + } + } + if v, ok := raw["lookback_days"]; ok && v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("parse lookback_days: %w", err) + } + cfg.LookbackDays = n + } + if cfg.LookbackDays <= 0 { + return nil, fmt.Errorf("lookback_days must be positive") + } + if cfg.LookbackDays > maxLookbackDays { + return nil, fmt.Errorf("lookback_days must be <= %d", maxLookbackDays) + } + policyInputKey := "policy_inputs" + if _, ok := raw[policyInputKey]; !ok { + policyInputKey = "policy_input" + } + if v, ok := raw[policyInputKey]; ok && v != "" { + if err := json.Unmarshal([]byte(v), &cfg.PolicyInputs); err != nil { + return nil, fmt.Errorf("parse %s: %w", policyInputKey, err) + } + } + if v, ok := raw["policy_labels"]; ok && v != "" { + if err := json.Unmarshal([]byte(v), &cfg.PolicyLabels); err != nil { + return nil, fmt.Errorf("parse policy_labels: %w", err) + } + } + if v, ok := raw["max_concurrency"]; ok && v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("parse max_concurrency: %w", err) + } + cfg.MaxConcurrency = n + } + if cfg.MaxConcurrency <= 0 { + cfg.MaxConcurrency = 1 + } + if v, ok := raw["api_timeout_seconds"]; ok && v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("parse api_timeout_seconds: %w", err) + } + cfg.APITimeoutSeconds = n + } + if cfg.APITimeoutSeconds <= 0 { + return nil, fmt.Errorf("api_timeout_seconds must be positive") + } + for i := range cfg.Accounts { + if cfg.Accounts[i].Tags == nil { + cfg.Accounts[i].Tags = map[string]string{} + } + } + return cfg, nil +} + +func cloneStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +// clonePolicyInputs intentionally performs only a shallow top-level copy. +// Nested maps/slices remain shared; callers must deep-copy nested values before mutating. +func clonePolicyInputs(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..550c76a --- /dev/null +++ b/config_test.go @@ -0,0 +1,65 @@ +package main + +import "testing" + +func TestParsePluginConfigDefaults(t *testing.T) { + cfg, err := parsePluginConfig(map[string]string{}) + if err != nil { + t.Fatalf("parse defaults: %v", err) + } + if cfg.LookbackDays != defaultLookbackDays { + t.Fatalf("lookback = %d", cfg.LookbackDays) + } + if cfg.MaxConcurrency != 4 { + t.Fatalf("max concurrency = %d", cfg.MaxConcurrency) + } + if cfg.APITimeoutSeconds != 120 { + t.Fatalf("timeout = %d", cfg.APITimeoutSeconds) + } +} + +func TestParsePluginConfigValidation(t *testing.T) { + tests := map[string]map[string]string{ + "lookback zero": {"lookback_days": "0"}, + "lookback over max": {"lookback_days": "91"}, + "malformed accounts": {"accounts": "{bad"}, + "malformed policy_inputs": {"policy_inputs": "{bad"}, + "malformed policy_labels": {"policy_labels": "{bad"}, + "bad api timeout": {"api_timeout_seconds": "0"}, + "non-numeric concurrency": {"max_concurrency": "x"}, + "non-numeric lookback": {"lookback_days": "x"}, + "malformed default regions": {"default_regions": "{bad"}, + } + for name, raw := range tests { + t.Run(name, func(t *testing.T) { + if _, err := parsePluginConfig(raw); err == nil { + t.Fatalf("expected error") + } + }) + } +} + +func TestParsePluginConfigStructuredValues(t *testing.T) { + cfg, err := parsePluginConfig(map[string]string{ + "accounts": `[{"account_id":"123","regions":["us-east-1"],"tags":{"environment":"prod"}}]`, + "default_regions": `["us-west-2"]`, + "policy_input": `{"minimum":30}`, + "policy_labels": `{"team":"security"}`, + "max_concurrency": "0", + }) + if err != nil { + t.Fatalf("parse config: %v", err) + } + if cfg.MaxConcurrency != 1 { + t.Fatalf("zero concurrency should normalize to 1, got %d", cfg.MaxConcurrency) + } + if cfg.Accounts[0].Tags["environment"] != "prod" { + t.Fatalf("account tags not parsed") + } + if cfg.PolicyInputs["minimum"].(float64) != 30 { + t.Fatalf("policy input not parsed") + } + if cfg.PolicyLabels["team"] != "security" { + t.Fatalf("policy labels not parsed") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9129443 --- /dev/null +++ b/go.mod @@ -0,0 +1,69 @@ +module github.com/compliance-framework/plugin-aws-secretsmanager + +go 1.26.1 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.12 + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 + github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.51.0 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 + 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/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/aws/smithy-go v1.25.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/compliance-framework/api v0.16.0 // 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/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/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/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..444d74a --- /dev/null +++ b/go.sum @@ -0,0 +1,336 @@ +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +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/air-verse/air v1.61.5/go.mod h1:QW4HkIASdtSnwaYof1zgJCSxd41ebvix10t5ubtm9cg= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= +github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.51.0 h1:mEDXhybFN7q39EBrN3SiZt0sebBU18ZNUuvOPftYI84= +github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.51.0/go.mod h1:bAz9Mfw6YqILCw087zDfCyDuZNs4wK4S+G+JSHBSyW0= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3/go.mod h1:vBfBu24Ka3/5UZtepbTV0gnc9VPLT8ok+0oDDaYAzn4= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10/go.mod h1:Diyyyz0b43X13pdi1mVMqlTwDjOmRbJMvDsqnduUYWM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12 h1:xN4mw6Gqim0jMwjmlNST+yXVShFPwSAjt4gXqi43W6I= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12/go.mod h1:QgVIY03/XoQs2iFr0MbQuQ/Tf1RwlkOvuySWMh1wph4= +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.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0/go.mod h1:046/oLyFlYdAghYQE2yHXi/E//VM5Cf3/dFmA+3CZ0c= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= +github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/bytecodealliance/wasmtime-go/v39 v39.0.1/go.mod h1:miR4NYIEBXeDNamZIzpskhJ0z/p8al+lwMWylQ/ZJb4= +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/compliance-framework/gooci v0.0.6/go.mod h1:vbiRPS2mbxW2VIKhpkOOK6uftKjv9l3fYOr3m+ufwZA= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/gohugoio/hugo v0.159.2/go.mod h1:vKww5V9i8MYzFD8JVvhRN+AKdDfKV0UvbFpmCDtTr/A= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +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/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= +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/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/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +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/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +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/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/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +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/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +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/go.mod h1:x9tVfiCrbOctSAmaYP00iE5YlO8zh3Y9leFk6wP6aCk= +github.com/riverqueue/river/riverdriver v0.30.1/go.mod h1:WBB9w6LftQtoZgRhNstqhP7MyBKt09XJkzluSNwMMoY= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.30.1/go.mod h1:4oSf8jYWZaEwmJ3R5LmOMiGlV9uuvCWOJ3uyBfTwWCc= +github.com/riverqueue/river/rivershared v0.30.1/go.mod h1:PfmUHWkF6/fJ1CpjC4cG8eKciBXgMuIHgcRcIuHMc34= +github.com/riverqueue/river/rivertype v0.30.1/go.mod h1:rWpgI59doOWS6zlVocROcwc00fZ1RbzRwsRTU8CDguw= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +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/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= +github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= +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/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +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/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +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/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +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/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +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/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +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/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/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/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +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/input.go b/input.go new file mode 100644 index 0000000..13cd14b --- /dev/null +++ b/input.go @@ -0,0 +1,137 @@ +package main + +import ( + "strings" + "time" + + "github.com/compliance-framework/agent/runner/proto" +) + +const ( + sourceName = "aws-secretsmanager" + schemaVersionV1 = "v1" + resourceTypeSecret = "secret" +) + +type AccountContext struct { + AccountID string `json:"account_id"` + RoleARN string `json:"role_arn,omitempty"` + Tags map[string]string `json:"tags,omitempty"` +} + +type RegionContext struct { + Name string `json:"name"` +} + +type ResourceIdentity struct { + ID string `json:"id"` + ARN string `json:"arn"` + Type string `json:"type"` +} + +type Window struct { + Start string `json:"start"` + End string `json:"end"` +} + +type CollectionError struct { + Scope string `json:"scope"` + Message string `json:"message"` +} + +type CollectionMetadata struct { + CollectedAt string `json:"collected_at"` + CollectorVersion string `json:"collector_version"` + CollectionType string `json:"collection_type"` + Errors []CollectionError `json:"errors"` + RawPayloadHashes map[string]string `json:"raw_payload_hashes,omitempty"` + LookbackWindow *Window `json:"lookback_window,omitempty"` +} + +type NormalizedInput struct { + SchemaVersion string `json:"schema_version"` + Source string `json:"source"` + Account AccountContext `json:"account"` + Region RegionContext `json:"region"` + Resource ResourceIdentity `json:"resource"` + Config map[string]interface{} `json:"config"` + Dynamic map[string]interface{} `json:"dynamic"` + Tags map[string]string `json:"tags"` + Collection CollectionMetadata `json:"collection"` + PolicyInputs map[string]interface{} `json:"policy_inputs"` +} + +type ResourceRecord struct { + Input NormalizedInput + Labels map[string]string + SubjectID string + SubjectType proto.SubjectType + Title string + Raw interface{} +} + +func newSecretRecord(target ResolvedTarget, arn string, config map[string]interface{}, dynamic map[string]interface{}, tags map[string]string, errors []CollectionError, hashes map[string]string, lookback *Window, policyInputs map[string]interface{}, cloudTrailCollected bool, collectedAt time.Time) *ResourceRecord { + id := secretIDFromARN(arn) + collectionType := "config" + if cloudTrailCollected { + collectionType = "config_dynamic" + } + labels := map[string]string{ + "provider": "aws", + "type": "secretsmanager", + "subject": "aws-secretsmanager-secret", + "account_id": target.AccountID, + "region": target.Region, + "resource_id": id, + "resource_arn": arn, + "resource_type": resourceTypeSecret, + } + for k, v := range target.Tags { + labels["account_tag_"+k] = v + } + input := NormalizedInput{ + SchemaVersion: schemaVersionV1, + Source: sourceName, + Account: AccountContext{ + AccountID: target.AccountID, + RoleARN: target.RoleARN, + Tags: target.Tags, + }, + Region: RegionContext{Name: target.Region}, + Resource: ResourceIdentity{ID: id, ARN: arn, Type: resourceTypeSecret}, + Config: config, + Dynamic: dynamic, + Tags: tags, + Collection: CollectionMetadata{ + CollectedAt: collectedAt.UTC().Format(time.RFC3339), + CollectorVersion: sourceName, + CollectionType: collectionType, + Errors: errors, + RawPayloadHashes: hashes, + LookbackWindow: lookback, + }, + PolicyInputs: clonePolicyInputs(policyInputs), + } + return &ResourceRecord{ + Input: input, + Labels: labels, + SubjectID: target.AccountID + ":" + target.Region + ":" + arn, + SubjectType: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Title: "Secrets Manager secret " + id, + Raw: config, + } +} + +func secretIDFromARN(arn string) string { + if idx := strings.LastIndex(arn, ":"); idx >= 0 && idx+1 < len(arn) { + return arn[idx+1:] + } + return arn +} + +func secretNameWithoutSuffix(id string) string { + if len(id) > 7 && id[len(id)-7] == '-' { + return id[:len(id)-7] + } + return id +} diff --git a/internal/util.go b/internal/util.go new file mode 100644 index 0000000..cbe597f --- /dev/null +++ b/internal/util.go @@ -0,0 +1,67 @@ +package internal + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +func MergeMaps(a, b map[string]string) map[string]string { + out := make(map[string]string, len(a)+len(b)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + out[k] = v + } + return out +} + +func StringAddressed(v string) *string { + return &v +} + +func FormatTime(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +func Sha256Hex(payload []byte) string { + sum := sha256.Sum256(payload) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func HashString(v string) string { + return Sha256Hex([]byte(v)) +} + +func StringValue(v *string) string { + if v == nil { + return "" + } + return *v +} + +func BoolValue(v *bool) bool { + if v == nil { + return false + } + return *v +} + +func Int64Value(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +func ErrorString(scope string, err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("%s: %v", scope, err) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2a99cba --- /dev/null +++ b/main.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "sync" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/agent/runner/proto" + "github.com/compliance-framework/plugin-aws-secretsmanager/internal" + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" +) + +var defaultPolicyBehaviors = map[string][]string{ + "plugin-aws-secretsmanager-rotation-policies": {resourceTypeSecret}, + "plugin-aws-secretsmanager-vendor-policies": {resourceTypeSecret}, + "plugin-aws-secretsmanager-confidentiality-policies": {resourceTypeSecret}, + "plugin-aws-secretsmanager-privacy-policies": {resourceTypeSecret}, + "plugin-aws-secretsmanager-secret-policies": {resourceTypeSecret}, +} + +func requestWithDefaultPolicyBehavior(req *proto.EvalRequest) *proto.EvalRequest { + if req == nil { + return nil + } + return req. + WithDefaultPolicyBehavior(defaultPolicyBehaviors). + WithUndefinedMappedTo([]string{resourceTypeSecret}) +} + +type CompliancePlugin struct { + mu sync.RWMutex + logger hclog.Logger + rawConfig map[string]string + parsedConfig *PluginConfig + factory AWSClientFactory + policyData map[string]interface{} +} + +func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { + parsed, err := parsePluginConfig(req.GetConfig()) + if err != nil { + return nil, err + } + l.mu.Lock() + defer l.mu.Unlock() + l.rawConfig = cloneStringMap(req.GetConfig()) + l.parsedConfig = parsed + if req.GetPolicyData() != nil { + l.policyData = req.GetPolicyData().AsMap() + } else { + l.policyData = parsed.PolicyInputs + } + return &proto.ConfigureResponse{}, nil +} + +func (l *CompliancePlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { + ctx := context.Background() + return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, buildSubjectTemplates()) +} + +func (l *CompliancePlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { + if req == nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("eval request is nil") + } + ctx := context.Background() + + l.mu.RLock() + parsedConfig := l.parsedConfig + policyData := l.policyData + l.mu.RUnlock() + if parsedConfig == nil || policyData == nil { + l.mu.Lock() + if l.parsedConfig == nil { + defaults, err := parsePluginConfig(map[string]string{}) + if err != nil { + l.mu.Unlock() + return nil, err + } + l.parsedConfig = defaults + } + if l.policyData == nil { + l.policyData = map[string]interface{}{} + } + parsedConfig = l.parsedConfig + policyData = clonePolicyInputs(l.policyData) + l.mu.Unlock() + } else { + policyData = clonePolicyInputs(policyData) + } + policyLabels := cloneStringMap(parsedConfig.PolicyLabels) + + policyRequest := requestWithDefaultPolicyBehavior(req) + pathsByType := map[string][]string{ + resourceTypeSecret: policyRequest.PolicyPathsForBehavior(resourceTypeSecret), + } + + logger := l.logger + if logger == nil { + logger = hclog.NewNullLogger() + } + collector := &Collector{Logger: logger.Named("collector"), Config: parsedConfig, Factory: l.factory} + result := collector.Collect(ctx) + + evidences := make([]*proto.Evidence, 0) + var accumulated error + accumulated = errors.Join(accumulated, result.Err) + for _, record := range result.Records { + recordEvidence, err := l.evaluateRecord(ctx, pathsByType[record.Input.Resource.Type], record, policyLabels, policyData) + evidences = append(evidences, recordEvidence...) + accumulated = errors.Join(accumulated, err) + } + + if len(evidences) > 0 && apiHelper != nil { + if createErr := apiHelper.CreateEvidence(ctx, evidences); createErr != nil { + accumulated = errors.Join(accumulated, createErr) + } + } + if accumulated != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, accumulated + } + return &proto.EvalResponse{Status: proto.ExecutionStatus_SUCCESS}, nil +} + +func (l *CompliancePlugin) evaluateRecord( + ctx context.Context, + policyPaths []string, + record *ResourceRecord, + policyLabels map[string]string, + policyData map[string]interface{}, +) ([]*proto.Evidence, error) { + var accumulated error + evidences := make([]*proto.Evidence, 0) + labels := internal.MergeMaps(policyLabels, record.Labels) + activities := []*proto.Activity{{ + Title: "Collect AWS Secrets Manager evidence", + Description: "Collected read-only Secrets Manager configuration, resource policies, version stages, and CloudTrail data for policy evaluation.", + Steps: []*proto.Step{ + {Title: "Fetch read-only AWS data", Description: "Used AWS SDK read-only Secrets Manager, CloudTrail, and STS APIs."}, + {Title: "Normalize Rego input", Description: "Converted SDK payloads into the documented aws-secretsmanager Rego input schema."}, + }, + }} + input, err := regoInputMap(record.Input) + if err != nil { + return nil, err + } + for _, policyPath := range policyPaths { + processor := policyManager.NewPolicyProcessor( + l.logger, labels, subjectsForRecord(*record), defaultComponents(), + inventoryForRecord(*record), defaultActors(), activities, policyData, + ) + evidence, perr := processor.GenerateResults(ctx, policyPath, input) + evidences = append(evidences, evidence...) + if perr != nil { + accumulated = errors.Join(accumulated, perr) + } + } + return evidences, accumulated +} + +func regoInputMap(input NormalizedInput) (map[string]interface{}, error) { + encoded, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal Rego input: %w", err) + } + out := map[string]interface{}{} + if err := json.Unmarshal(encoded, &out); err != nil { + return nil, fmt.Errorf("unmarshal Rego input: %w", err) + } + return out, nil +} + +func buildSubjectTemplates() []*proto.SubjectTemplate { + return []*proto.SubjectTemplate{ + { + Name: "aws-secretsmanager-secret", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "Secrets Manager secret {{ .resource_id }} in {{ .account_id }}/{{ .region }}", + DescriptionTemplate: "AWS Secrets Manager secret {{ .resource_id }}.", + PurposeTemplate: "Represents a Secrets Manager secret evaluated for compliance posture.", + IdentityLabelKeys: []string{"account_id", "region", "resource_id"}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "account_id", Description: "AWS account ID"}, + {Key: "region", Description: "AWS region"}, + {Key: "resource_id", Description: "Secret friendly name + 6-char suffix"}, + {Key: "resource_arn", Description: "Secret ARN"}, + }, + }, + } +} + +func subjectsForRecord(record ResourceRecord) []*proto.Subject { + return []*proto.Subject{{ + Identifier: record.SubjectID, + Type: record.SubjectType, + Description: "AWS Secrets Manager secret " + record.Input.Resource.ID, + }} +} + +func defaultComponents() []*proto.Component { + return []*proto.Component{{ + Identifier: sourceName, + Type: "software", + Title: "AWS Secrets Manager collector", + Description: "Read-only collector for AWS Secrets Manager compliance evidence.", + }} +} + +func inventoryForRecord(record ResourceRecord) []*proto.InventoryItem { + return []*proto.InventoryItem{{ + Identifier: record.Input.Resource.ARN, + Type: "aws-secretsmanager-secret", + Title: record.Title, + Description: "AWS Secrets Manager secret in " + record.Input.Region.Name, + }} +} + +func defaultActors() []*proto.OriginActor { + return []*proto.OriginActor{{ + UUID: sourceName, + Title: "AWS Secrets Manager evidence collector", + Type: "tool", + }} +} + +func defaultLogLevel() hclog.Level { + switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) { + case "debug": + return hclog.Debug + case "warn": + return hclog.Warn + case "error": + return hclog.Error + case "info": + return hclog.Info + default: + return hclog.Info + } +} + +func main() { + logger := hclog.New(&hclog.LoggerOptions{Level: defaultLogLevel(), JSONFormat: true}) + goplugin.Serve(&goplugin.ServeConfig{ + HandshakeConfig: runner.HandshakeConfig, + Plugins: map[string]goplugin.Plugin{"runner": &runner.RunnerV2GRPCPlugin{Impl: &CompliancePlugin{logger: logger}}}, + GRPCServer: goplugin.DefaultGRPCServer, + }) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..80e87d9 --- /dev/null +++ b/main_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/cloudtrail" + sm "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" +) + +type emptySM struct{} + +func (emptySM) ListSecrets(context.Context, *sm.ListSecretsInput, ...func(*sm.Options)) (*sm.ListSecretsOutput, error) { + return &sm.ListSecretsOutput{}, nil +} +func (emptySM) DescribeSecret(context.Context, *sm.DescribeSecretInput, ...func(*sm.Options)) (*sm.DescribeSecretOutput, error) { + return &sm.DescribeSecretOutput{}, nil +} +func (emptySM) GetResourcePolicy(context.Context, *sm.GetResourcePolicyInput, ...func(*sm.Options)) (*sm.GetResourcePolicyOutput, error) { + return &sm.GetResourcePolicyOutput{}, nil +} +func (emptySM) ListSecretVersionIds(context.Context, *sm.ListSecretVersionIdsInput, ...func(*sm.Options)) (*sm.ListSecretVersionIdsOutput, error) { + return &sm.ListSecretVersionIdsOutput{}, nil +} + +type emptyCT struct{} + +func (emptyCT) LookupEvents(context.Context, *cloudtrail.LookupEventsInput, ...func(*cloudtrail.Options)) (*cloudtrail.LookupEventsOutput, error) { + return &cloudtrail.LookupEventsOutput{}, nil +} + +func TestEvalNilRequest(t *testing.T) { + p := &CompliancePlugin{logger: hclog.NewNullLogger()} + resp, err := p.Eval(nil, nil) + if err == nil { + t.Fatalf("expected error") + } + if resp == nil { + t.Fatalf("expected non-nil response when Eval returns an error") + } + if resp.GetStatus() != proto.ExecutionStatus_FAILURE { + t.Fatalf("status = %v", resp.GetStatus()) + } +} + +func TestEvalNilLoggerDoesNotPanic(t *testing.T) { + p := &CompliancePlugin{ + factory: fakeFactory{ + targets: []ResolvedTarget{{AccountID: "123456789012", Region: "us-east-1"}}, + set: AWSClientSet{SecretsManager: emptySM{}, CloudTrail: emptyCT{}, STS: fakeSTS{}}, + }, + } + resp, err := p.Eval(&proto.EvalRequest{}, nil) + if err != nil { + t.Fatalf("eval: %v", err) + } + if resp == nil || resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Fatalf("resp = %v", resp) + } +} + +func TestDefaultLogLevel(t *testing.T) { + t.Setenv("LOG_LEVEL", "") + if got := defaultLogLevel(); got != hclog.Info { + t.Fatalf("empty LOG_LEVEL = %v", got) + } + t.Setenv("LOG_LEVEL", "debug") + if got := defaultLogLevel(); got != hclog.Debug { + t.Fatalf("debug LOG_LEVEL = %v", got) + } + t.Setenv("LOG_LEVEL", "bogus") + if got := defaultLogLevel(); got != hclog.Info { + t.Fatalf("unknown LOG_LEVEL = %v", got) + } +} + +func TestSecretSubjectTemplateAndRecordUseComponentType(t *testing.T) { + templates := buildSubjectTemplates() + if len(templates) != 1 { + t.Fatalf("templates = %d", len(templates)) + } + if templates[0].GetName() != "aws-secretsmanager-secret" { + t.Fatalf("template name = %s", templates[0].GetName()) + } + if templates[0].GetType() != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("template type = %v", templates[0].GetType()) + } + + record := newSecretRecord( + ResolvedTarget{AccountID: "123456789012", Region: "us-east-1"}, + "arn:aws:secretsmanager:us-east-1:123456789012:secret:App/db-AbCdEf", + map[string]interface{}{}, + map[string]interface{}{}, + nil, + nil, + nil, + nil, + nil, + false, + time.Now(), + ) + if record.SubjectType != proto.SubjectType_SUBJECT_TYPE_COMPONENT { + t.Fatalf("record subject type = %v", record.SubjectType) + } + if record.Input.Resource.Type != resourceTypeSecret { + t.Fatalf("resource type = %s", record.Input.Resource.Type) + } + if record.Input.Resource.ID != "App/db-AbCdEf" { + t.Fatalf("resource id = %s", record.Input.Resource.ID) + } +} + +func TestConfigureEvalConcurrent(t *testing.T) { + p := &CompliancePlugin{ + logger: hclog.NewNullLogger(), + factory: fakeFactory{ + targets: []ResolvedTarget{{AccountID: "123456789012", Region: "us-east-1"}}, + set: AWSClientSet{SecretsManager: emptySM{}, CloudTrail: emptyCT{}, STS: fakeSTS{}}, + }, + } + if _, err := p.Configure(&proto.ConfigureRequest{Config: map[string]string{"policy_inputs": `{"x":1}`}}); err != nil { + t.Fatalf("configure: %v", err) + } + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if resp, err := p.Eval(&proto.EvalRequest{}, nil); err != nil || resp.GetStatus() != proto.ExecutionStatus_SUCCESS { + t.Errorf("eval resp=%v err=%v", resp, err) + } + }() + } + wg.Wait() +} diff --git a/rego_fixture_test.go b/rego_fixture_test.go new file mode 100644 index 0000000..0b2ae38 --- /dev/null +++ b/rego_fixture_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" +) + +func TestInlineRegoPoliciesAgainstSecretRecord(t *testing.T) { + dir := t.TempDir() + policy := `package compliance_framework.aws_secretsmanager.fixture + +import future.keywords.if + +title := "fixture" + +violation[json.marshal({"id": "wildcard-principal", "title": "Wildcard principal"})] if { + some p in input.config.resource_policy.principals + p.principal == "*" +} + +violation[json.marshal({"id": "rotation-disabled", "title": "Rotation disabled"})] if { + input.config.rotation_enabled == false + input.config.owning_service == "" +} + +violation[json.marshal({"id": "confidential-default-kms", "title": "Confidential secret uses default KMS"})] if { + input.tags.DataClassification == "confidential" + input.config.kms_key_id == "aws/secretsmanager" +} +` + if err := os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(policy), 0o600); err != nil { + t.Fatalf("write policy: %v", err) + } + record := newSecretRecord( + ResolvedTarget{AccountID: "123456789012", Region: "us-east-1"}, + "arn:aws:secretsmanager:us-east-1:123456789012:secret:App/db-AbCdEf", + map[string]interface{}{ + "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:App/db-AbCdEf", + "kms_key_id": "aws/secretsmanager", + "rotation_enabled": false, + "owning_service": "", + "resource_policy": map[string]interface{}{"principals": []map[string]interface{}{ + {"principal": "*", "action": []string{"secretsmanager:GetSecretValue"}, "condition": nil, "effect": "Allow"}, + }}, + }, + map[string]interface{}{"cloudtrail_events": []normalizedEvent{}, "iam_credential_removal_events": []normalizedEvent{}}, + map[string]string{"DataClassification": "confidential"}, + nil, + map[string]string{}, + nil, + map[string]interface{}{}, + false, + time.Now(), + ) + plugin := &CompliancePlugin{logger: hclog.NewNullLogger()} + evidence, err := plugin.evaluateRecord(context.Background(), []string{dir}, record, map[string]string{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(evidence) != 1 { + t.Fatalf("evidence count = %d", len(evidence)) + } + if evidence[0].GetStatus().GetState() != proto.EvidenceStatusState_EVIDENCE_STATUS_STATE_NOT_SATISFIED { + t.Fatalf("state = %v", evidence[0].GetStatus().GetState()) + } + if len(evidence[0].GetProps()) != 3 { + t.Fatalf("violations = %d", len(evidence[0].GetProps())) + } +}