diff --git a/.changeset/cold-wasps-tan.md b/.changeset/cold-wasps-tan.md new file mode 100644 index 0000000..eff11f1 --- /dev/null +++ b/.changeset/cold-wasps-tan.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/sdk": patch +--- + +Add AWS discovery dataset support for idle NAT gateways and running SageMaker notebook instances. diff --git a/.changeset/gentle-pandas-smoke.md b/.changeset/gentle-pandas-smoke.md new file mode 100644 index 0000000..5f8ed59 --- /dev/null +++ b/.changeset/gentle-pandas-smoke.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/rules": patch +--- + +Add built-in AWS discovery rules for idle NAT gateways and running SageMaker notebook instances. diff --git a/docs/architecture/rules.md b/docs/architecture/rules.md index 78e0523..a6c9003 100644 --- a/docs/architecture/rules.md +++ b/docs/architecture/rules.md @@ -91,33 +91,38 @@ Rule evaluators consume static and live datasets through `context.resources.get( ## Current Rules -| ID | Name | Service | Supports | Status | -| --------------------- | ----------------------------------------- | ------- | -------------- | ----------- | -| `CLDBRN-AWS-CLOUDTRAIL-1` | CloudTrail Redundant Global Trails | cloudtrail | discovery | Implemented | -| `CLDBRN-AWS-CLOUDTRAIL-2` | CloudTrail Redundant Regional Trails | cloudtrail | discovery | Implemented | -| `CLDBRN-AWS-CLOUDWATCH-1` | CloudWatch Log Group Missing Retention | cloudwatch | discovery | Implemented | -| `CLDBRN-AWS-CLOUDWATCH-2` | CloudWatch Unused Log Streams | cloudwatch | discovery | Implemented | -| `CLDBRN-AWS-EC2-1` | EC2 Instance Type Not Preferred | ec2 | iac, discovery | Implemented | -| `CLDBRN-AWS-EC2-2` | S3 Interface VPC Endpoint Used | ec2 | iac | Implemented | -| `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-4` | VPC Interface Endpoint Inactive | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-5` | EC2 Instance Low Utilization | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EBS-1` | EBS Volume Type Not Current Generation | ebs | discovery, iac | Implemented | -| `CLDBRN-AWS-EBS-2` | EBS Volume Unattached | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-3` | EBS Volume Attached To Stopped Instances | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-7` | EBS Snapshot Max Age Exceeded | ebs | discovery | Implemented | -| `CLDBRN-AWS-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented | -| `CLDBRN-AWS-RDS-1` | RDS Instance Class Not Preferred | rds | iac, discovery | Implemented | -| `CLDBRN-AWS-RDS-2` | RDS DB Instance Idle | rds | discovery | Implemented | -| `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented | -| `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented | -| `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented | +| ID | Name | Service | Supports | Status | +| ------------------------- | ------------------------------------------ | ---------- | -------------- | ----------- | +| `CLDBRN-AWS-CLOUDTRAIL-1` | CloudTrail Redundant Global Trails | cloudtrail | discovery | Implemented | +| `CLDBRN-AWS-CLOUDTRAIL-2` | CloudTrail Redundant Regional Trails | cloudtrail | discovery | Implemented | +| `CLDBRN-AWS-CLOUDWATCH-1` | CloudWatch Log Group Missing Retention | cloudwatch | discovery | Implemented | +| `CLDBRN-AWS-CLOUDWATCH-2` | CloudWatch Unused Log Streams | cloudwatch | discovery | Implemented | +| `CLDBRN-AWS-EC2-1` | EC2 Instance Type Not Preferred | ec2 | iac, discovery | Implemented | +| `CLDBRN-AWS-EC2-2` | S3 Interface VPC Endpoint Used | ec2 | iac | Implemented | +| `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-4` | VPC Interface Endpoint Inactive | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-5` | EC2 Instance Low Utilization | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-10` | EC2 Instance Detailed Monitoring Enabled | ec2 | iac | Implemented | +| `CLDBRN-AWS-EC2-11` | NAT Gateway Idle | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EBS-1` | EBS Volume Type Not Current Generation | ebs | discovery, iac | Implemented | +| `CLDBRN-AWS-EBS-2` | EBS Volume Unattached | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-3` | EBS Volume Attached To Stopped Instances | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-7` | EBS Snapshot Max Age Exceeded | ebs | discovery | Implemented | +| `CLDBRN-AWS-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented | +| `CLDBRN-AWS-RDS-1` | RDS Instance Class Not Preferred | rds | iac, discovery | Implemented | +| `CLDBRN-AWS-RDS-2` | RDS DB Instance Idle | rds | discovery | Implemented | +| `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented | +| `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented | +| `CLDBRN-AWS-SAGEMAKER-1` | SageMaker Notebook Instance Running | sagemaker | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented | `CLDBRN-AWS-LAMBDA-1` is an advisory rule. It recommends `arm64` only when compatibility is known or explicitly declared, and the static evaluator skips computed or otherwise unknown architecture values instead of treating them as definite `x86_64`. CloudTrail and CloudWatch discovery rules now rely on dedicated live datasets. CloudBurn seeds both CloudWatch datasets from Resource Explorer `logs:log-group` catalog results, then uses narrow CloudWatch Logs APIs to hydrate group retention metadata and enumerate log streams. EBS discovery rules now reuse the shared `aws-ebs-volumes` dataset for storage type, attachment, size, and IOPS checks, and use a dedicated `aws-ebs-snapshots` dataset seeded from Resource Explorer `ec2:snapshot` resources for snapshot-age review. + +NAT gateway and SageMaker notebook discovery follow the same catalog-first model: CloudBurn seeds NAT review from `ec2:natgateway` resources and hydrates 7-day traffic totals with `DescribeNatGateways` plus CloudWatch metrics, while SageMaker notebook review is seeded from `sagemaker:notebook-instance` resources and hydrated through `DescribeNotebookInstance`. diff --git a/docs/architecture/sdk.md b/docs/architecture/sdk.md index 783dcae..d88acd2 100644 --- a/docs/architecture/sdk.md +++ b/docs/architecture/sdk.md @@ -81,7 +81,7 @@ Current live-discovery behavior: - Resource Explorer inventory failures and dataset loader failures are fatal. The SDK does not degrade to partial live results. - Missing Lambda `Architectures` values from AWS are normalized to `['x86_64']`, matching the AWS default architecture. - Lambda hydrators limit in-flight `GetFunctionConfiguration` calls per region to avoid API throttling in large accounts. -- Live scans require Resource Explorer access plus narrow hydrator permissions such as `apigateway:GetStage`, `application-autoscaling:DescribeScalableTargets`, `application-autoscaling:DescribeScalingPolicies`, `ce:GetCostAndUsage`, `cloudfront:GetDistribution`, `cloudfront:ListDistributions`, `cloudtrail:DescribeTrails`, `cloudwatch:GetMetricData`, `dynamodb:DescribeTable`, `ecs:DescribeContainerInstances`, `ecs:DescribeServices`, `ec2:DescribeVolumes`, `ec2:DescribeInstances`, `eks:ListNodegroups`, `eks:DescribeNodegroup`, `lambda:GetFunctionConfiguration`, `rds:DescribeDBInstances`, `route53:ListHealthChecks`, `route53:ListHostedZones`, `route53:ListResourceRecordSets`, `s3:GetLifecycleConfiguration`, `s3:GetIntelligentTieringConfiguration`, and `secretsmanager:DescribeSecret`. +- Live scans require Resource Explorer access plus narrow hydrator permissions such as `apigateway:GetStage`, `application-autoscaling:DescribeScalableTargets`, `application-autoscaling:DescribeScalingPolicies`, `ce:GetCostAndUsage`, `cloudfront:GetDistribution`, `cloudfront:ListDistributions`, `cloudtrail:DescribeTrails`, `cloudwatch:GetMetricData`, `dynamodb:DescribeTable`, `ecs:DescribeContainerInstances`, `ecs:DescribeServices`, `ec2:DescribeInstances`, `ec2:DescribeNatGateways`, `ec2:DescribeVolumes`, `eks:ListNodegroups`, `eks:DescribeNodegroup`, `lambda:GetFunctionConfiguration`, `rds:DescribeDBInstances`, `route53:ListHealthChecks`, `route53:ListHostedZones`, `route53:ListResourceRecordSets`, `s3:GetLifecycleConfiguration`, `s3:GetIntelligentTieringConfiguration`, `sagemaker:DescribeNotebookInstance`, and `secretsmanager:DescribeSecret`. ## Public Result Shape diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index 16d8e78..e6ee011 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -14,81 +14,83 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` ## Rule Table -| ID | Name | Service | Supports | Status | -| --------------------- | ----------------------------------------- | ------- | -------------- | ----------- | -| `CLDBRN-AWS-APIGATEWAY-1` | API Gateway Stage Caching Disabled | apigateway | discovery, iac | Implemented | -| `CLDBRN-AWS-CLOUDFRONT-1` | CloudFront Distribution Price Class All | cloudfront | discovery, iac | Implemented | -| `CLDBRN-AWS-CLOUDFRONT-2` | CloudFront Distribution Unused | cloudfront | discovery | Implemented | -| `CLDBRN-AWS-CLOUDTRAIL-1` | CloudTrail Redundant Global Trails | cloudtrail | discovery | Implemented | -| `CLDBRN-AWS-CLOUDTRAIL-2` | CloudTrail Redundant Regional Trails | cloudtrail | discovery | Implemented | -| `CLDBRN-AWS-CLOUDWATCH-1` | CloudWatch Log Group Missing Retention | cloudwatch | discovery, iac | Implemented | -| `CLDBRN-AWS-CLOUDWATCH-2` | CloudWatch Unused Log Streams | cloudwatch | discovery | Implemented | -| `CLDBRN-AWS-CLOUDWATCH-3` | CloudWatch Log Group No Metric Filters | cloudwatch | discovery | Implemented | -| `CLDBRN-AWS-COSTGUARDRAILS-1` | AWS Budgets Missing | costguardrails | discovery | Implemented | -| `CLDBRN-AWS-COSTGUARDRAILS-2` | Cost Anomaly Detection Missing | costguardrails | discovery | Implemented | -| `CLDBRN-AWS-COSTEXPLORER-1` | Cost Explorer Full Month Cost Changes | costexplorer | discovery | Implemented | -| `CLDBRN-AWS-DYNAMODB-1` | DynamoDB Table Stale Data | dynamodb | discovery | Implemented | -| `CLDBRN-AWS-DYNAMODB-2` | DynamoDB Table Without Autoscaling | dynamodb | discovery, iac | Implemented | -| `CLDBRN-AWS-DYNAMODB-3` | DynamoDB Table Unused | dynamodb | discovery | Implemented | -| `CLDBRN-AWS-DYNAMODB-4` | DynamoDB Autoscaling Range Fixed | dynamodb | iac | Implemented | -| `CLDBRN-AWS-EC2-1` | EC2 Instance Type Not Preferred | ec2 | iac, discovery | Implemented | -| `CLDBRN-AWS-EC2-2` | S3 Interface VPC Endpoint Used | ec2 | iac | Implemented | -| `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery, iac | Implemented | -| `CLDBRN-AWS-EC2-4` | VPC Interface Endpoint Inactive | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-5` | EC2 Instance Low Utilization | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-6` | EC2 Instance Without Graviton | ec2 | discovery, iac | Implemented | -| `CLDBRN-AWS-EC2-7` | EC2 Reserved Instance Expiring | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-8` | EC2 Instance Large Size | ec2 | discovery, iac | Implemented | -| `CLDBRN-AWS-EC2-9` | EC2 Instance Long Running | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-10` | EC2 Instance Detailed Monitoring Enabled | ec2 | iac | Implemented | -| `CLDBRN-AWS-ECS-1` | ECS Container Instance Without Graviton | ecs | discovery | Implemented | -| `CLDBRN-AWS-ECS-2` | ECS Cluster Low CPU Utilization | ecs | discovery | Implemented | -| `CLDBRN-AWS-ECS-3` | ECS Service Missing Autoscaling Policy | ecs | discovery, iac | Implemented | -| `CLDBRN-AWS-EBS-1` | EBS Volume Type Not Current Generation | ebs | discovery, iac | Implemented | -| `CLDBRN-AWS-EBS-2` | EBS Volume Unattached | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-3` | EBS Volume Attached To Stopped Instances | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery, iac | Implemented | -| `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery, iac | Implemented | -| `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery, iac | Implemented | -| `CLDBRN-AWS-EBS-7` | EBS Snapshot Max Age Exceeded | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-8` | EBS gp3 Volume Extra Throughput Provisioned | ebs | iac | Implemented | -| `CLDBRN-AWS-EBS-9` | EBS gp3 Volume Extra IOPS Provisioned | ebs | iac | Implemented | -| `CLDBRN-AWS-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented | -| `CLDBRN-AWS-ECR-2` | ECR Lifecycle Policy Missing Untagged Image Expiry | ecr | iac | Implemented | -| `CLDBRN-AWS-ECR-3` | ECR Lifecycle Policy Missing Tagged Image Retention Cap | ecr | iac | Implemented | -| `CLDBRN-AWS-EKS-1` | EKS Node Group Without Graviton | eks | discovery, iac | Implemented | -| `CLDBRN-AWS-ELASTICACHE-1` | ElastiCache Cluster Missing Reserved Coverage | elasticache | discovery | Implemented | -| `CLDBRN-AWS-ELASTICACHE-2` | ElastiCache Cluster Idle | elasticache | discovery | Implemented | -| `CLDBRN-AWS-ELB-1` | Application Load Balancer Without Targets | elb | discovery | Implemented | -| `CLDBRN-AWS-ELB-2` | Classic Load Balancer Without Instances | elb | discovery | Implemented | -| `CLDBRN-AWS-ELB-3` | Gateway Load Balancer Without Targets | elb | discovery | Implemented | -| `CLDBRN-AWS-ELB-4` | Network Load Balancer Without Targets | elb | discovery | Implemented | -| `CLDBRN-AWS-ELB-5` | Load Balancer Idle | elb | discovery | Implemented | -| `CLDBRN-AWS-EMR-1` | EMR Cluster Previous Generation Instance Types | emr | discovery, iac | Implemented | -| `CLDBRN-AWS-EMR-2` | EMR Cluster Idle | emr | discovery | Implemented | -| `CLDBRN-AWS-RDS-1` | RDS Instance Class Not Preferred | rds | iac, discovery | Implemented | -| `CLDBRN-AWS-RDS-2` | RDS DB Instance Idle | rds | discovery | Implemented | -| `CLDBRN-AWS-RDS-3` | RDS DB Instance Missing Reserved Coverage | rds | discovery | Implemented | -| `CLDBRN-AWS-RDS-4` | RDS DB Instance Without Graviton | rds | discovery, iac | Implemented | -| `CLDBRN-AWS-RDS-5` | RDS DB Instance Low CPU Utilization | rds | discovery | Implemented | -| `CLDBRN-AWS-RDS-6` | RDS DB Instance Unsupported Engine Version | rds | discovery, iac | Implemented | -| `CLDBRN-AWS-RDS-7` | RDS Snapshot Without Source DB Instance | rds | discovery | Implemented | -| `CLDBRN-AWS-RDS-8` | RDS Performance Insights Extended Retention | rds | iac | Implemented | -| `CLDBRN-AWS-REDSHIFT-1` | Redshift Cluster Low CPU Utilization | redshift | discovery | Implemented | -| `CLDBRN-AWS-REDSHIFT-2` | Redshift Cluster Missing Reserved Coverage | redshift | discovery | Implemented | -| `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery, iac | Implemented | -| `CLDBRN-AWS-ROUTE53-1` | Route 53 Record Higher TTL | route53 | discovery, iac | Implemented | -| `CLDBRN-AWS-ROUTE53-2` | Route 53 Health Check Unused | route53 | discovery, iac | Implemented | -| `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented | -| `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented | -| `CLDBRN-AWS-S3-3` | S3 Incomplete Multipart Upload Abort Configuration | s3 | iac, discovery | Implemented | -| `CLDBRN-AWS-S3-4` | S3 Versioned Bucket Missing Noncurrent Version Cleanup | s3 | iac | Implemented | -| `CLDBRN-AWS-SECRETSMANAGER-1` | Secrets Manager Secret Unused | secretsmanager | discovery | Implemented | -| `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented | -| `CLDBRN-AWS-LAMBDA-2` | Lambda Function High Error Rate | lambda | discovery | Implemented | -| `CLDBRN-AWS-LAMBDA-3` | Lambda Function Excessive Timeout | lambda | discovery | Implemented | -| `CLDBRN-AWS-LAMBDA-4` | Lambda Function Memory Overprovisioned | lambda | discovery | Implemented | -| `CLDBRN-AWS-LAMBDA-5` | Lambda Provisioned Concurrency Configured | lambda | iac | Implemented | +| ID | Name | Service | Supports | Status | +| ----------------------------- | ------------------------------------------------------- | -------------- | -------------- | ----------- | +| `CLDBRN-AWS-APIGATEWAY-1` | API Gateway Stage Caching Disabled | apigateway | discovery, iac | Implemented | +| `CLDBRN-AWS-CLOUDFRONT-1` | CloudFront Distribution Price Class All | cloudfront | discovery, iac | Implemented | +| `CLDBRN-AWS-CLOUDFRONT-2` | CloudFront Distribution Unused | cloudfront | discovery | Implemented | +| `CLDBRN-AWS-CLOUDTRAIL-1` | CloudTrail Redundant Global Trails | cloudtrail | discovery | Implemented | +| `CLDBRN-AWS-CLOUDTRAIL-2` | CloudTrail Redundant Regional Trails | cloudtrail | discovery | Implemented | +| `CLDBRN-AWS-CLOUDWATCH-1` | CloudWatch Log Group Missing Retention | cloudwatch | discovery, iac | Implemented | +| `CLDBRN-AWS-CLOUDWATCH-2` | CloudWatch Unused Log Streams | cloudwatch | discovery | Implemented | +| `CLDBRN-AWS-CLOUDWATCH-3` | CloudWatch Log Group No Metric Filters | cloudwatch | discovery | Implemented | +| `CLDBRN-AWS-COSTGUARDRAILS-1` | AWS Budgets Missing | costguardrails | discovery | Implemented | +| `CLDBRN-AWS-COSTGUARDRAILS-2` | Cost Anomaly Detection Missing | costguardrails | discovery | Implemented | +| `CLDBRN-AWS-COSTEXPLORER-1` | Cost Explorer Full Month Cost Changes | costexplorer | discovery | Implemented | +| `CLDBRN-AWS-DYNAMODB-1` | DynamoDB Table Stale Data | dynamodb | discovery | Implemented | +| `CLDBRN-AWS-DYNAMODB-2` | DynamoDB Table Without Autoscaling | dynamodb | discovery, iac | Implemented | +| `CLDBRN-AWS-DYNAMODB-3` | DynamoDB Table Unused | dynamodb | discovery | Implemented | +| `CLDBRN-AWS-DYNAMODB-4` | DynamoDB Autoscaling Range Fixed | dynamodb | iac | Implemented | +| `CLDBRN-AWS-EC2-1` | EC2 Instance Type Not Preferred | ec2 | iac, discovery | Implemented | +| `CLDBRN-AWS-EC2-2` | S3 Interface VPC Endpoint Used | ec2 | iac | Implemented | +| `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery, iac | Implemented | +| `CLDBRN-AWS-EC2-4` | VPC Interface Endpoint Inactive | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-5` | EC2 Instance Low Utilization | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-6` | EC2 Instance Without Graviton | ec2 | discovery, iac | Implemented | +| `CLDBRN-AWS-EC2-7` | EC2 Reserved Instance Expiring | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-8` | EC2 Instance Large Size | ec2 | discovery, iac | Implemented | +| `CLDBRN-AWS-EC2-9` | EC2 Instance Long Running | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-10` | EC2 Instance Detailed Monitoring Enabled | ec2 | iac | Implemented | +| `CLDBRN-AWS-EC2-11` | NAT Gateway Idle | ec2 | discovery | Implemented | +| `CLDBRN-AWS-ECS-1` | ECS Container Instance Without Graviton | ecs | discovery | Implemented | +| `CLDBRN-AWS-ECS-2` | ECS Cluster Low CPU Utilization | ecs | discovery | Implemented | +| `CLDBRN-AWS-ECS-3` | ECS Service Missing Autoscaling Policy | ecs | discovery, iac | Implemented | +| `CLDBRN-AWS-EBS-1` | EBS Volume Type Not Current Generation | ebs | discovery, iac | Implemented | +| `CLDBRN-AWS-EBS-2` | EBS Volume Unattached | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-3` | EBS Volume Attached To Stopped Instances | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery, iac | Implemented | +| `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery, iac | Implemented | +| `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery, iac | Implemented | +| `CLDBRN-AWS-EBS-7` | EBS Snapshot Max Age Exceeded | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-8` | EBS gp3 Volume Extra Throughput Provisioned | ebs | iac | Implemented | +| `CLDBRN-AWS-EBS-9` | EBS gp3 Volume Extra IOPS Provisioned | ebs | iac | Implemented | +| `CLDBRN-AWS-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented | +| `CLDBRN-AWS-ECR-2` | ECR Lifecycle Policy Missing Untagged Image Expiry | ecr | iac | Implemented | +| `CLDBRN-AWS-ECR-3` | ECR Lifecycle Policy Missing Tagged Image Retention Cap | ecr | iac | Implemented | +| `CLDBRN-AWS-EKS-1` | EKS Node Group Without Graviton | eks | discovery, iac | Implemented | +| `CLDBRN-AWS-ELASTICACHE-1` | ElastiCache Cluster Missing Reserved Coverage | elasticache | discovery | Implemented | +| `CLDBRN-AWS-ELASTICACHE-2` | ElastiCache Cluster Idle | elasticache | discovery | Implemented | +| `CLDBRN-AWS-ELB-1` | Application Load Balancer Without Targets | elb | discovery | Implemented | +| `CLDBRN-AWS-ELB-2` | Classic Load Balancer Without Instances | elb | discovery | Implemented | +| `CLDBRN-AWS-ELB-3` | Gateway Load Balancer Without Targets | elb | discovery | Implemented | +| `CLDBRN-AWS-ELB-4` | Network Load Balancer Without Targets | elb | discovery | Implemented | +| `CLDBRN-AWS-ELB-5` | Load Balancer Idle | elb | discovery | Implemented | +| `CLDBRN-AWS-EMR-1` | EMR Cluster Previous Generation Instance Types | emr | discovery, iac | Implemented | +| `CLDBRN-AWS-EMR-2` | EMR Cluster Idle | emr | discovery | Implemented | +| `CLDBRN-AWS-RDS-1` | RDS Instance Class Not Preferred | rds | iac, discovery | Implemented | +| `CLDBRN-AWS-RDS-2` | RDS DB Instance Idle | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-3` | RDS DB Instance Missing Reserved Coverage | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-4` | RDS DB Instance Without Graviton | rds | discovery, iac | Implemented | +| `CLDBRN-AWS-RDS-5` | RDS DB Instance Low CPU Utilization | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-6` | RDS DB Instance Unsupported Engine Version | rds | discovery, iac | Implemented | +| `CLDBRN-AWS-RDS-7` | RDS Snapshot Without Source DB Instance | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-8` | RDS Performance Insights Extended Retention | rds | iac | Implemented | +| `CLDBRN-AWS-REDSHIFT-1` | Redshift Cluster Low CPU Utilization | redshift | discovery | Implemented | +| `CLDBRN-AWS-REDSHIFT-2` | Redshift Cluster Missing Reserved Coverage | redshift | discovery | Implemented | +| `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery, iac | Implemented | +| `CLDBRN-AWS-ROUTE53-1` | Route 53 Record Higher TTL | route53 | discovery, iac | Implemented | +| `CLDBRN-AWS-ROUTE53-2` | Route 53 Health Check Unused | route53 | discovery, iac | Implemented | +| `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented | +| `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented | +| `CLDBRN-AWS-S3-3` | S3 Incomplete Multipart Upload Abort Configuration | s3 | iac, discovery | Implemented | +| `CLDBRN-AWS-S3-4` | S3 Versioned Bucket Missing Noncurrent Version Cleanup | s3 | iac | Implemented | +| `CLDBRN-AWS-SAGEMAKER-1` | SageMaker Notebook Instance Running | sagemaker | discovery | Implemented | +| `CLDBRN-AWS-SECRETSMANAGER-1` | Secrets Manager Secret Unused | secretsmanager | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-2` | Lambda Function High Error Rate | lambda | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-3` | Lambda Function Excessive Timeout | lambda | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-4` | Lambda Function Memory Overprovisioned | lambda | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-5` | Lambda Provisioned Concurrency Configured | lambda | iac | Implemented | `CLDBRN-AWS-APIGATEWAY-1` flags REST API stages when `cacheClusterEnabled` is not explicitly `true`. @@ -134,6 +136,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `CLDBRN-AWS-EC2-10` flags IaC-defined instances only when detailed monitoring is explicitly enabled. +`CLDBRN-AWS-EC2-11` flags only NAT gateways in the `available` state and requires complete 7-day `BytesInFromDestination` and `BytesOutToDestination` coverage, with both totals equal to `0`. + `CLDBRN-AWS-ECS-1` flags only EC2-backed container instances whose instance families have a curated Graviton-equivalent path. Fargate and unclassified backing instances are skipped. `CLDBRN-AWS-ECS-2` flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. @@ -196,6 +200,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `CLDBRN-AWS-S3-4` flags only versioned buckets and requires either noncurrent-version expiration or transition cleanup to avoid unbounded version growth. +`CLDBRN-AWS-SAGEMAKER-1` flags only notebook instances whose normalized status remains `InService`. + `CLDBRN-AWS-SECRETSMANAGER-1` flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old. **Status key:** diff --git a/packages/rules/src/aws/ec2/idle-nat-gateway.ts b/packages/rules/src/aws/ec2/idle-nat-gateway.ts new file mode 100644 index 0000000..e800958 --- /dev/null +++ b/packages/rules/src/aws/ec2/idle-nat-gateway.ts @@ -0,0 +1,30 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-EC2-11'; +const RULE_SERVICE = 'ec2'; +const RULE_MESSAGE = 'NAT gateways should process traffic or be removed.'; + +/** Flag available NAT gateways that have processed no traffic in either direction for 7 days. */ +export const ec2IdleNatGatewayRule = createRule({ + id: RULE_ID, + name: 'NAT Gateway Idle', + description: 'Flag available NAT gateways whose inbound and outbound traffic both stay at zero for 7 days.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-ec2-nat-gateway-activity'], + evaluateLive: ({ resources }) => { + const findings = resources + .get('aws-ec2-nat-gateway-activity') + .filter( + (natGateway) => + natGateway.state === 'available' && + natGateway.bytesInFromDestinationLast7Days === 0 && + natGateway.bytesOutToDestinationLast7Days === 0, + ) + .map((natGateway) => createFindingMatch(natGateway.natGatewayId, natGateway.region, natGateway.accountId)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/ec2/index.ts b/packages/rules/src/aws/ec2/index.ts index 7bef350..c0442b5 100644 --- a/packages/rules/src/aws/ec2/index.ts +++ b/packages/rules/src/aws/ec2/index.ts @@ -1,5 +1,6 @@ import { ec2DetailedMonitoringEnabledRule } from './detailed-monitoring-enabled.js'; import { ec2GravitonReviewRule } from './graviton-review.js'; +import { ec2IdleNatGatewayRule } from './idle-nat-gateway.js'; import { ec2InactiveVpcInterfaceEndpointRule } from './inactive-vpc-interface-endpoint.js'; import { ec2LargeInstanceRule } from './large-instance.js'; import { ec2LongRunningInstanceRule } from './long-running-instance.js'; @@ -21,4 +22,5 @@ export const ec2Rules = [ ec2LargeInstanceRule, ec2LongRunningInstanceRule, ec2DetailedMonitoringEnabledRule, + ec2IdleNatGatewayRule, ]; diff --git a/packages/rules/src/aws/index.ts b/packages/rules/src/aws/index.ts index b4d5026..d9f02cb 100644 --- a/packages/rules/src/aws/index.ts +++ b/packages/rules/src/aws/index.ts @@ -18,6 +18,7 @@ import { rdsRules } from './rds/index.js'; import { redshiftRules } from './redshift/index.js'; import { route53Rules } from './route53/index.js'; import { s3Rules } from './s3/index.js'; +import { sagemakerRules } from './sagemaker/index.js'; import { secretsmanagerRules } from './secretsmanager/index.js'; // Intent: aggregate all AWS rules into a single provider collection. @@ -42,6 +43,7 @@ export const awsRules = [ ...redshiftRules, ...route53Rules, ...s3Rules, + ...sagemakerRules, ...secretsmanagerRules, ...lambdaRules, ]; diff --git a/packages/rules/src/aws/sagemaker/index.ts b/packages/rules/src/aws/sagemaker/index.ts new file mode 100644 index 0000000..fc91871 --- /dev/null +++ b/packages/rules/src/aws/sagemaker/index.ts @@ -0,0 +1,4 @@ +import { sagemakerRunningNotebookInstanceRule } from './running-notebook-instance.js'; + +/** Aggregate AWS SageMaker rule definitions. */ +export const sagemakerRules = [sagemakerRunningNotebookInstanceRule]; diff --git a/packages/rules/src/aws/sagemaker/running-notebook-instance.ts b/packages/rules/src/aws/sagemaker/running-notebook-instance.ts new file mode 100644 index 0000000..d306254 --- /dev/null +++ b/packages/rules/src/aws/sagemaker/running-notebook-instance.ts @@ -0,0 +1,25 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-SAGEMAKER-1'; +const RULE_SERVICE = 'sagemaker'; +const RULE_MESSAGE = 'SageMaker notebook instances should not remain running when they are no longer needed.'; + +/** Flag SageMaker notebook instances that are currently in service. */ +export const sagemakerRunningNotebookInstanceRule = createRule({ + id: RULE_ID, + name: 'SageMaker Notebook Instance Running', + description: 'Flag SageMaker notebook instances whose status remains InService.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-sagemaker-notebook-instances'], + evaluateLive: ({ resources }) => { + const findings = resources + .get('aws-sagemaker-notebook-instances') + .filter((instance) => instance.notebookInstanceStatus === 'InService') + .map((instance) => createFindingMatch(instance.notebookInstanceName, instance.region, instance.accountId)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 55a1658..838ed59 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -35,6 +35,7 @@ export type { AwsEc2InstanceUtilization, AwsEc2LoadBalancer, AwsEc2LoadBalancerRequestActivity, + AwsEc2NatGatewayActivity, AwsEc2ReservedInstance, AwsEc2TargetGroup, AwsEc2VpcEndpointActivity, @@ -66,6 +67,7 @@ export type { AwsRoute53Zone, AwsS3BucketAnalysis, AwsS3BucketAnalysisFlags, + AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsStaticApiGatewayStage, AwsStaticCloudFrontDistribution, diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index fdba14a..74ac9a7 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -182,6 +182,19 @@ export type AwsEc2ElasticIp = { accountId: string; }; +/** Discovered NAT gateway with 7-day traffic totals for idle checks. */ +export type AwsEc2NatGatewayActivity = { + natGatewayId: string; + subnetId: string; + state: string; + /** `null` means CloudWatch returned incomplete datapoints for the 7-day lookback window. */ + bytesInFromDestinationLast7Days: number | null; + /** `null` means CloudWatch returned incomplete datapoints for the 7-day lookback window. */ + bytesOutToDestinationLast7Days: number | null; + region: string; + accountId: string; +}; + /** Discovered ElastiCache cluster normalized for reservation checks. */ export type AwsElastiCacheCluster = { cacheClusterId: string; @@ -277,6 +290,16 @@ export type AwsLambdaFunctionMetric = { accountId: string; }; +/** Discovered SageMaker notebook instance normalized for running-state checks. */ +export type AwsSageMakerNotebookInstance = { + notebookInstanceName: string; + notebookInstanceStatus: string; + instanceType: string; + lastModifiedTime?: string; + region: string; + accountId: string; +}; + /** Discovered AWS RDS DB instance with its normalized instance class. */ export type AwsRdsInstance = { dbInstanceIdentifier: string; @@ -646,6 +669,7 @@ export type DiscoveryDatasetKey = | 'aws-ec2-elastic-ips' | 'aws-ec2-instances' | 'aws-ec2-instance-utilization' + | 'aws-ec2-nat-gateway-activity' | 'aws-ec2-load-balancer-request-activity' | 'aws-ec2-load-balancers' | 'aws-ec2-reserved-instances' @@ -668,6 +692,7 @@ export type DiscoveryDatasetKey = | 'aws-route53-records' | 'aws-route53-zones' | 'aws-s3-bucket-analyses' + | 'aws-sagemaker-notebook-instances' | 'aws-secretsmanager-secrets'; /** Normalized live discovery datasets available to rule evaluators. */ @@ -699,6 +724,7 @@ export type DiscoveryDatasetMap = { 'aws-ec2-elastic-ips': AwsEc2ElasticIp[]; 'aws-ec2-instances': AwsEc2Instance[]; 'aws-ec2-instance-utilization': AwsEc2InstanceUtilization[]; + 'aws-ec2-nat-gateway-activity': AwsEc2NatGatewayActivity[]; 'aws-ec2-load-balancer-request-activity': AwsEc2LoadBalancerRequestActivity[]; 'aws-ec2-load-balancers': AwsEc2LoadBalancer[]; 'aws-ec2-reserved-instances': AwsEc2ReservedInstance[]; @@ -721,6 +747,7 @@ export type DiscoveryDatasetMap = { 'aws-route53-records': AwsRoute53Record[]; 'aws-route53-zones': AwsRoute53Zone[]; 'aws-s3-bucket-analyses': AwsS3BucketAnalysis[]; + 'aws-sagemaker-notebook-instances': AwsSageMakerNotebookInstance[]; 'aws-secretsmanager-secrets': AwsSecretsManagerSecret[]; }; diff --git a/packages/rules/test/ec2-idle-nat-gateway.test.ts b/packages/rules/test/ec2-idle-nat-gateway.test.ts new file mode 100644 index 0000000..7b0699f --- /dev/null +++ b/packages/rules/test/ec2-idle-nat-gateway.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { ec2IdleNatGatewayRule } from '../src/aws/ec2/idle-nat-gateway.js'; +import type { AwsEc2NatGatewayActivity } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createNatGateway = (overrides: Partial = {}): AwsEc2NatGatewayActivity => ({ + accountId: '123456789012', + bytesInFromDestinationLast7Days: 0, + bytesOutToDestinationLast7Days: 0, + natGatewayId: 'nat-123', + region: 'us-east-1', + state: 'available', + subnetId: 'subnet-123', + ...overrides, +}); + +describe('ec2IdleNatGatewayRule', () => { + it('flags available NAT gateways when both inbound and outbound traffic stay at zero for 7 days', () => { + const finding = ec2IdleNatGatewayRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-nat-gateway-activity': [createNatGateway()], + }), + }); + + expect(finding).toEqual({ + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'nat-123', + }, + ], + message: 'NAT gateways should process traffic or be removed.', + ruleId: 'CLDBRN-AWS-EC2-11', + service: 'ec2', + source: 'discovery', + }); + }); + + it('skips NAT gateways when either traffic direction has activity', () => { + const finding = ec2IdleNatGatewayRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-nat-gateway-activity': [createNatGateway({ bytesOutToDestinationLast7Days: 128 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips NAT gateways when CloudWatch coverage is incomplete', () => { + const finding = ec2IdleNatGatewayRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-nat-gateway-activity': [createNatGateway({ bytesInFromDestinationLast7Days: null })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index f7d9a09..41d2249 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -13,6 +13,7 @@ import type { AwsEc2Instance, AwsEc2LoadBalancer, AwsEc2LoadBalancerRequestActivity, + AwsEc2NatGatewayActivity, AwsEc2ReservedInstance, AwsEc2TargetGroup, AwsEcsClusterMetric, @@ -30,6 +31,7 @@ import type { AwsRoute53HealthCheck, AwsRoute53Record, AwsRoute53Zone, + AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsStaticRdsInstance, DiscoveryDatasetKey, @@ -77,6 +79,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-EC2-8', 'CLDBRN-AWS-EC2-9', 'CLDBRN-AWS-EC2-10', + 'CLDBRN-AWS-EC2-11', 'CLDBRN-AWS-ECS-1', 'CLDBRN-AWS-ECS-2', 'CLDBRN-AWS-ECS-3', @@ -121,6 +124,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-S3-2', 'CLDBRN-AWS-S3-3', 'CLDBRN-AWS-S3-4', + 'CLDBRN-AWS-SAGEMAKER-1', 'CLDBRN-AWS-SECRETSMANAGER-1', ]), ); @@ -222,6 +226,15 @@ describe('rule exports', () => { reservedInstancesId: 'abcd1234-ef56-7890-abcd-1234567890ab', state: 'active', }; + const natGatewayActivity: AwsEc2NatGatewayActivity = { + accountId: '123456789012', + bytesInFromDestinationLast7Days: 0, + bytesOutToDestinationLast7Days: 0, + natGatewayId: 'nat-123', + region: 'us-east-1', + state: 'available', + subnetId: 'subnet-123', + }; const loadBalancer: AwsEc2LoadBalancer = { accountId: '123456789012', @@ -414,6 +427,14 @@ describe('rule exports', () => { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AbCdEf', secretName: 'db-password', }; + const notebookInstance: AwsSageMakerNotebookInstance = { + accountId: '123456789012', + instanceType: 'ml.t3.medium', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + notebookInstanceName: 'analytics-notebook', + notebookInstanceStatus: 'InService', + region: 'eu-west-1', + }; const apiGatewayDatasetKey: DiscoveryDatasetKey = 'aws-apigateway-stages'; const cloudFrontDatasetKey: DiscoveryDatasetKey = 'aws-cloudfront-distributions'; @@ -430,6 +451,7 @@ describe('rule exports', () => { const elastiCacheReservedDatasetKey: DiscoveryDatasetKey = 'aws-elasticache-reserved-nodes'; const loadBalancerDatasetKey: DiscoveryDatasetKey = 'aws-ec2-load-balancers'; const loadBalancerRequestActivityDatasetKey: DiscoveryDatasetKey = 'aws-ec2-load-balancer-request-activity'; + const natGatewayDatasetKey: DiscoveryDatasetKey = 'aws-ec2-nat-gateway-activity'; const emrDatasetKey: DiscoveryDatasetKey = 'aws-emr-clusters'; const emrMetricDatasetKey: DiscoveryDatasetKey = 'aws-emr-cluster-metrics'; const reservedInstanceDatasetKey: DiscoveryDatasetKey = 'aws-ec2-reserved-instances'; @@ -439,6 +461,7 @@ describe('rule exports', () => { const route53HealthCheckDatasetKey: DiscoveryDatasetKey = 'aws-route53-health-checks'; const route53RecordDatasetKey: DiscoveryDatasetKey = 'aws-route53-records'; const route53ZoneDatasetKey: DiscoveryDatasetKey = 'aws-route53-zones'; + const sagemakerDatasetKey: DiscoveryDatasetKey = 'aws-sagemaker-notebook-instances'; const secretsManagerDatasetKey: DiscoveryDatasetKey = 'aws-secretsmanager-secrets'; const targetGroupDatasetKey: DiscoveryDatasetKey = 'aws-ec2-target-groups'; const staticDatasetKey: StaticDatasetKey = 'aws-rds-instances'; @@ -458,6 +481,7 @@ describe('rule exports', () => { expect(elastiCacheReservedDatasetKey).toBe('aws-elasticache-reserved-nodes'); expect(loadBalancerDatasetKey).toBe('aws-ec2-load-balancers'); expect(loadBalancerRequestActivityDatasetKey).toBe('aws-ec2-load-balancer-request-activity'); + expect(natGatewayDatasetKey).toBe('aws-ec2-nat-gateway-activity'); expect(emrDatasetKey).toBe('aws-emr-clusters'); expect(emrMetricDatasetKey).toBe('aws-emr-cluster-metrics'); expect(reservedInstanceDatasetKey).toBe('aws-ec2-reserved-instances'); @@ -467,6 +491,7 @@ describe('rule exports', () => { expect(route53HealthCheckDatasetKey).toBe('aws-route53-health-checks'); expect(route53RecordDatasetKey).toBe('aws-route53-records'); expect(route53ZoneDatasetKey).toBe('aws-route53-zones'); + expect(sagemakerDatasetKey).toBe('aws-sagemaker-notebook-instances'); expect(secretsManagerDatasetKey).toBe('aws-secretsmanager-secrets'); expect(cloudFrontDistribution.priceClass).toBe('PriceClass_All'); expect(costUsage.costIncrease).toBe(15); @@ -475,9 +500,11 @@ describe('rule exports', () => { expect(targetGroupDatasetKey).toBe('aws-ec2-target-groups'); expect(cloudFrontRequestActivity.totalRequestsLast30Days).toBe(42); expect(loadBalancerRequestActivity.averageRequestsPerDayLast14Days).toBe(7); + expect(natGatewayActivity.natGatewayId).toBe('nat-123'); expect(route53Zone.zoneName).toBe('example.com.'); expect(route53Record.ttl).toBe(300); expect(route53HealthCheck.healthCheckId).toBe('abcd1234'); + expect(notebookInstance.notebookInstanceStatus).toBe('InService'); expect(secret.secretName).toBe('db-password'); expect(cacheCluster.cacheClusterStatus).toBe('available'); expect(cacheClusterActivity.averageCacheHitRateLast14Days).toBe(4.5); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index c4b725c..47b46a2 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -720,6 +720,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected EC2 idle NAT gateway rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-11'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-EC2-11', + name: 'NAT Gateway Idle', + description: 'Flag available NAT gateways whose inbound and outbound traffic both stay at zero for 7 days.', + message: 'NAT gateways should process traffic or be removed.', + provider: 'aws', + service: 'ec2', + supports: ['discovery'], + discoveryDependencies: ['aws-ec2-nat-gateway-activity'], + }); + }); + it('defines the expected ELB ALB-without-targets rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-1'); @@ -1052,6 +1068,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected SageMaker notebook-running rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-SAGEMAKER-1'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-SAGEMAKER-1', + name: 'SageMaker Notebook Instance Running', + description: 'Flag SageMaker notebook instances whose status remains InService.', + message: 'SageMaker notebook instances should not remain running when they are no longer needed.', + provider: 'aws', + service: 'sagemaker', + supports: ['discovery'], + discoveryDependencies: ['aws-sagemaker-notebook-instances'], + }); + }); + it('defines the expected CloudFront price-class rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-CLOUDFRONT-1'); diff --git a/packages/rules/test/sagemaker-running-notebook-instance.test.ts b/packages/rules/test/sagemaker-running-notebook-instance.test.ts new file mode 100644 index 0000000..07762cf --- /dev/null +++ b/packages/rules/test/sagemaker-running-notebook-instance.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { sagemakerRunningNotebookInstanceRule } from '../src/aws/sagemaker/running-notebook-instance.js'; +import type { AwsSageMakerNotebookInstance } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createNotebookInstance = ( + overrides: Partial = {}, +): AwsSageMakerNotebookInstance => ({ + accountId: '123456789012', + instanceType: 'ml.t3.medium', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + notebookInstanceName: 'analytics-notebook', + notebookInstanceStatus: 'InService', + region: 'eu-west-1', + ...overrides, +}); + +describe('sagemakerRunningNotebookInstanceRule', () => { + it('flags notebook instances that are currently in service', () => { + const finding = sagemakerRunningNotebookInstanceRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'eu-west-1', + }, + resources: new LiveResourceBag({ + 'aws-sagemaker-notebook-instances': [createNotebookInstance()], + }), + }); + + expect(finding).toEqual({ + findings: [ + { + accountId: '123456789012', + region: 'eu-west-1', + resourceId: 'analytics-notebook', + }, + ], + message: 'SageMaker notebook instances should not remain running when they are no longer needed.', + ruleId: 'CLDBRN-AWS-SAGEMAKER-1', + service: 'sagemaker', + source: 'discovery', + }); + }); + + it('skips notebook instances that are not in service', () => { + const finding = sagemakerRunningNotebookInstanceRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'eu-west-1', + }, + resources: new LiveResourceBag({ + 'aws-sagemaker-notebook-instances': [createNotebookInstance({ notebookInstanceStatus: 'Stopped' })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 80bb79c..65c4e9b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -66,6 +66,7 @@ "@aws-sdk/client-resource-explorer-2": "^3.1003.0", "@aws-sdk/client-route-53": "^3.1015.0", "@aws-sdk/client-s3": "^3.1006.0", + "@aws-sdk/client-sagemaker": "^3.1019.0", "@aws-sdk/client-secrets-manager": "^3.1015.0", "@aws-sdk/client-sts": "^3.1003.0", "@cdktf/hcl2json": "^0.21.0", diff --git a/packages/sdk/src/providers/aws/client.ts b/packages/sdk/src/providers/aws/client.ts index 6ea5adf..2f7105d 100644 --- a/packages/sdk/src/providers/aws/client.ts +++ b/packages/sdk/src/providers/aws/client.ts @@ -21,6 +21,7 @@ import { RedshiftClient } from '@aws-sdk/client-redshift'; import { ResourceExplorer2Client } from '@aws-sdk/client-resource-explorer-2'; import { Route53Client } from '@aws-sdk/client-route-53'; import { S3Client } from '@aws-sdk/client-s3'; +import { SageMakerClient } from '@aws-sdk/client-sagemaker'; import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; import { AwsDiscoveryError } from './errors.js'; @@ -181,6 +182,12 @@ export const createS3Client = (config: AwsClientConfig): S3Client => region: config.region, }); +/** Creates an AWS SageMaker client for a specific region. */ +export const createSageMakerClient = (config: AwsClientConfig): SageMakerClient => + new SageMakerClient({ + region: config.region, + }); + /** Creates an AWS Secrets Manager client for a specific region. */ export const createSecretsManagerClient = (config: AwsClientConfig): SecretsManagerClient => new SecretsManagerClient({ diff --git a/packages/sdk/src/providers/aws/discovery-registry.ts b/packages/sdk/src/providers/aws/discovery-registry.ts index 4f91b22..fdc90ba 100644 --- a/packages/sdk/src/providers/aws/discovery-registry.ts +++ b/packages/sdk/src/providers/aws/discovery-registry.ts @@ -21,6 +21,7 @@ import { import { hydrateAwsEbsSnapshots, hydrateAwsEbsVolumes } from './resources/ebs.js'; import { hydrateAwsEc2Instances } from './resources/ec2.js'; import { hydrateAwsEc2ElasticIps } from './resources/ec2-elastic-ips.js'; +import { hydrateAwsEc2NatGatewayActivity } from './resources/ec2-nat-gateways.js'; import { hydrateAwsEc2ReservedInstances } from './resources/ec2-reserved-instances.js'; import { hydrateAwsEc2InstanceUtilization } from './resources/ec2-utilization.js'; import { hydrateAwsEcrRepositories } from './resources/ecr.js'; @@ -53,6 +54,7 @@ import { hydrateAwsRoute53Zones, } from './resources/route53.js'; import { hydrateAwsS3BucketAnalyses } from './resources/s3.js'; +import { hydrateAwsSageMakerNotebookInstances } from './resources/sagemaker.js'; import { hydrateAwsSecretsManagerSecrets } from './resources/secretsmanager.js'; import { hydrateAwsEc2VpcEndpointActivity } from './resources/vpc-endpoints.js'; @@ -90,6 +92,7 @@ export type AwsDiscoveryDatasetDefinition Promise>; }; @@ -259,6 +262,12 @@ const awsDiscoveryDatasetRegistry: { service: 'ec2', load: hydrateAwsEc2InstanceUtilization, }, + 'aws-ec2-nat-gateway-activity': { + datasetKey: 'aws-ec2-nat-gateway-activity', + resourceTypes: ['ec2:natgateway'], + service: 'ec2', + load: hydrateAwsEc2NatGatewayActivity, + }, 'aws-ec2-load-balancers': { datasetKey: 'aws-ec2-load-balancers', resourceTypes: [ @@ -406,6 +415,12 @@ const awsDiscoveryDatasetRegistry: { service: 's3', load: hydrateAwsS3BucketAnalyses, }, + 'aws-sagemaker-notebook-instances': { + datasetKey: 'aws-sagemaker-notebook-instances', + resourceTypes: ['sagemaker:notebook-instance'], + service: 'sagemaker', + load: hydrateAwsSageMakerNotebookInstances, + }, 'aws-secretsmanager-secrets': { datasetKey: 'aws-secretsmanager-secrets', resourceTypes: ['secretsmanager:secret'], diff --git a/packages/sdk/src/providers/aws/resources/ec2-nat-gateways.ts b/packages/sdk/src/providers/aws/resources/ec2-nat-gateways.ts new file mode 100644 index 0000000..02ed51c --- /dev/null +++ b/packages/sdk/src/providers/aws/resources/ec2-nat-gateways.ts @@ -0,0 +1,147 @@ +import { DescribeNatGatewaysCommand } from '@aws-sdk/client-ec2'; +import type { AwsDiscoveredResource, AwsEc2NatGatewayActivity } from '@cloudburn/rules'; +import { createEc2Client } from '../client.js'; +import { fetchCloudWatchSignals } from './cloudwatch.js'; +import { chunkItems, withAwsServiceErrorContext } from './utils.js'; + +const NAT_GATEWAY_ARN_PREFIX = 'natgateway/'; +const NAT_GATEWAY_DESCRIBE_BATCH_SIZE = 100; +const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60; +const DAILY_PERIOD_IN_SECONDS = 24 * 60 * 60; +const REQUIRED_NAT_GATEWAY_DAILY_POINTS = SEVEN_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; + +const extractNatGatewayId = (resource: AwsDiscoveredResource): string | null => { + if (resource.name?.startsWith('nat-')) { + return resource.name; + } + + const resourceSegment = resource.arn.split(':')[5]; + + if (!resourceSegment?.startsWith(NAT_GATEWAY_ARN_PREFIX)) { + return null; + } + + return resourceSegment.slice(NAT_GATEWAY_ARN_PREFIX.length); +}; + +/** + * Hydrates discovered NAT gateways with recent traffic totals. + * + * @param resources - Catalog resources filtered to NAT gateway resource types. + * @returns Hydrated NAT gateway activity models for rule evaluation. + */ +export const hydrateAwsEc2NatGatewayActivity = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const resourcesByRegion = new Map>(); + + for (const resource of resources) { + const natGatewayId = extractNatGatewayId(resource); + + if (!natGatewayId) { + continue; + } + + const regionResources = resourcesByRegion.get(resource.region) ?? []; + regionResources.push({ + accountId: resource.accountId, + natGatewayId, + }); + resourcesByRegion.set(resource.region, regionResources); + } + + const hydratedPages = await Promise.all( + [...resourcesByRegion.entries()].map(async ([region, regionResources]) => { + const client = createEc2Client({ region }); + const natGateways: AwsEc2NatGatewayActivity[] = []; + + for (const batch of chunkItems(regionResources, NAT_GATEWAY_DESCRIBE_BATCH_SIZE)) { + const response = await withAwsServiceErrorContext('Amazon EC2', 'DescribeNatGateways', region, () => + client.send( + new DescribeNatGatewaysCommand({ + NatGatewayIds: batch.map(({ natGatewayId }) => natGatewayId), + }), + ), + ); + + const availableNatGateways = (response.NatGateways ?? []).flatMap((natGateway) => { + if ( + !natGateway.NatGatewayId || + !natGateway.SubnetId || + !natGateway.State || + natGateway.State !== 'available' + ) { + return []; + } + + const discoveredResource = batch.find(({ natGatewayId }) => natGatewayId === natGateway.NatGatewayId); + + if (!discoveredResource) { + return []; + } + + return [ + { + accountId: discoveredResource.accountId, + natGatewayId: natGateway.NatGatewayId, + state: natGateway.State, + subnetId: natGateway.SubnetId, + }, + ]; + }); + + if (availableNatGateways.length === 0) { + continue; + } + + const metricData = await fetchCloudWatchSignals({ + endTime: new Date(), + queries: availableNatGateways.flatMap((natGateway, index) => [ + { + dimensions: [{ Name: 'NatGatewayId', Value: natGateway.natGatewayId }], + id: `natIn${index}`, + metricName: 'BytesInFromDestination', + namespace: 'AWS/NATGateway', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + { + dimensions: [{ Name: 'NatGatewayId', Value: natGateway.natGatewayId }], + id: `natOut${index}`, + metricName: 'BytesOutToDestination', + namespace: 'AWS/NATGateway', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + ]), + region, + startTime: new Date(Date.now() - SEVEN_DAYS_IN_SECONDS * 1000), + }); + + natGateways.push( + ...availableNatGateways.map((natGateway, index) => { + const inboundPoints = metricData.get(`natIn${index}`) ?? []; + const outboundPoints = metricData.get(`natOut${index}`) ?? []; + + return { + ...natGateway, + bytesInFromDestinationLast7Days: + inboundPoints.length >= REQUIRED_NAT_GATEWAY_DAILY_POINTS + ? inboundPoints.reduce((sum, point) => sum + point.value, 0) + : null, + bytesOutToDestinationLast7Days: + outboundPoints.length >= REQUIRED_NAT_GATEWAY_DAILY_POINTS + ? outboundPoints.reduce((sum, point) => sum + point.value, 0) + : null, + region, + } satisfies AwsEc2NatGatewayActivity; + }), + ); + } + + return natGateways; + }), + ); + + return hydratedPages.flat().sort((left, right) => left.natGatewayId.localeCompare(right.natGatewayId)); +}; diff --git a/packages/sdk/src/providers/aws/resources/sagemaker.ts b/packages/sdk/src/providers/aws/resources/sagemaker.ts new file mode 100644 index 0000000..1c87564 --- /dev/null +++ b/packages/sdk/src/providers/aws/resources/sagemaker.ts @@ -0,0 +1,105 @@ +import { DescribeNotebookInstanceCommand } from '@aws-sdk/client-sagemaker'; +import type { AwsDiscoveredResource, AwsSageMakerNotebookInstance } from '@cloudburn/rules'; +import { createSageMakerClient } from '../client.js'; +import { chunkItems, extractTerminalResourceIdentifier, withAwsServiceErrorContext } from './utils.js'; + +const NOTEBOOK_INSTANCE_BATCH_SIZE = 10; + +const isNotebookInstanceMissingError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + const candidates = [error.name, error.message].map((value) => value.toLowerCase()); + + return ( + candidates.some((value) => value.includes('resourcenotfound')) || + candidates.some((value) => value.includes('could not find notebook instance')) || + candidates.some((value) => value.includes('notebook instance') && value.includes('not found')) || + candidates.some((value) => value.includes('validationexception') && value.includes('notebook instance')) + ); +}; + +/** + * Hydrates discovered SageMaker notebook instances with their runtime metadata. + * + * @param resources - Catalog resources filtered to SageMaker notebook instance resource types. + * @returns Hydrated notebook instances for rule evaluation. + */ +export const hydrateAwsSageMakerNotebookInstances = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const resourcesByRegion = new Map>(); + + for (const resource of resources) { + const notebookInstanceName = extractTerminalResourceIdentifier(resource.name, resource.arn); + + if (!notebookInstanceName) { + continue; + } + + const regionResources = resourcesByRegion.get(resource.region) ?? []; + regionResources.push({ + accountId: resource.accountId, + notebookInstanceName, + }); + resourcesByRegion.set(resource.region, regionResources); + } + + const hydratedPages = await Promise.all( + [...resourcesByRegion.entries()].map(async ([region, regionResources]) => { + const client = createSageMakerClient({ region }); + const notebookInstances: AwsSageMakerNotebookInstance[] = []; + + for (const batch of chunkItems(regionResources, NOTEBOOK_INSTANCE_BATCH_SIZE)) { + const hydratedBatch = await Promise.all( + batch.map(async (resource) => { + try { + const response = await withAwsServiceErrorContext( + 'Amazon SageMaker', + 'DescribeNotebookInstance', + region, + () => + client.send( + new DescribeNotebookInstanceCommand({ + NotebookInstanceName: resource.notebookInstanceName, + }), + ), + { + passthrough: isNotebookInstanceMissingError, + }, + ); + + if (!response.NotebookInstanceName || !response.NotebookInstanceStatus || !response.InstanceType) { + return null; + } + + return { + accountId: resource.accountId, + instanceType: response.InstanceType, + lastModifiedTime: response.LastModifiedTime?.toISOString(), + notebookInstanceName: response.NotebookInstanceName, + notebookInstanceStatus: response.NotebookInstanceStatus, + region, + } satisfies AwsSageMakerNotebookInstance; + } catch (error) { + if (isNotebookInstanceMissingError(error)) { + return null; + } + + throw error; + } + }), + ); + + notebookInstances.push(...hydratedBatch.flatMap((instance) => (instance ? [instance] : []))); + } + + return notebookInstances; + }), + ); + + return hydratedPages + .flat() + .sort((left, right) => left.notebookInstanceName.localeCompare(right.notebookInstanceName)); +}; diff --git a/packages/sdk/test/providers/aws-discovery.test.ts b/packages/sdk/test/providers/aws-discovery.test.ts index 61ff110..5be6768 100644 --- a/packages/sdk/test/providers/aws-discovery.test.ts +++ b/packages/sdk/test/providers/aws-discovery.test.ts @@ -42,6 +42,7 @@ import { } from '../../src/providers/aws/resources/dynamodb.js'; import { hydrateAwsEbsSnapshots, hydrateAwsEbsVolumes } from '../../src/providers/aws/resources/ebs.js'; import { hydrateAwsEc2Instances } from '../../src/providers/aws/resources/ec2.js'; +import { hydrateAwsEc2NatGatewayActivity } from '../../src/providers/aws/resources/ec2-nat-gateways.js'; import { hydrateAwsEc2ReservedInstances } from '../../src/providers/aws/resources/ec2-reserved-instances.js'; import { hydrateAwsEc2InstanceUtilization } from '../../src/providers/aws/resources/ec2-utilization.js'; import { hydrateAwsEcrRepositories } from '../../src/providers/aws/resources/ecr.js'; @@ -88,6 +89,7 @@ import { hydrateAwsRoute53Zones, } from '../../src/providers/aws/resources/route53.js'; import { hydrateAwsS3BucketAnalyses } from '../../src/providers/aws/resources/s3.js'; +import { hydrateAwsSageMakerNotebookInstances } from '../../src/providers/aws/resources/sagemaker.js'; import { hydrateAwsSecretsManagerSecrets } from '../../src/providers/aws/resources/secretsmanager.js'; vi.mock('../../src/providers/aws/client.js', async (importOriginal) => { @@ -187,6 +189,10 @@ vi.mock('../../src/providers/aws/resources/ec2.js', () => ({ hydrateAwsEc2Instances: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/ec2-nat-gateways.js', () => ({ + hydrateAwsEc2NatGatewayActivity: vi.fn(), +})); + vi.mock('../../src/providers/aws/resources/ec2-utilization.js', () => ({ hydrateAwsEc2InstanceUtilization: vi.fn(), })); @@ -233,6 +239,10 @@ vi.mock('../../src/providers/aws/resources/s3.js', () => ({ hydrateAwsS3BucketAnalyses: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/sagemaker.js', () => ({ + hydrateAwsSageMakerNotebookInstances: vi.fn(), +})); + vi.mock('../../src/providers/aws/resources/secretsmanager.js', () => ({ hydrateAwsSecretsManagerSecrets: vi.fn(), })); @@ -276,6 +286,7 @@ const mockedHydrateAwsEcrRepositories = vi.mocked(hydrateAwsEcrRepositories); const mockedHydrateAwsEmrClusterMetrics = vi.mocked(hydrateAwsEmrClusterMetrics); const mockedHydrateAwsEmrClusters = vi.mocked(hydrateAwsEmrClusters); const mockedHydrateAwsEc2Instances = vi.mocked(hydrateAwsEc2Instances); +const mockedHydrateAwsEc2NatGatewayActivity = vi.mocked(hydrateAwsEc2NatGatewayActivity); const mockedHydrateAwsEc2InstanceUtilization = vi.mocked(hydrateAwsEc2InstanceUtilization); const mockedHydrateAwsEc2ReservedInstances = vi.mocked(hydrateAwsEc2ReservedInstances); const mockedHydrateAwsEc2LoadBalancers = vi.mocked(hydrateAwsEc2LoadBalancers); @@ -296,6 +307,7 @@ const mockedHydrateAwsRoute53HealthChecks = vi.mocked(hydrateAwsRoute53HealthChe const mockedHydrateAwsRoute53Records = vi.mocked(hydrateAwsRoute53Records); const mockedHydrateAwsRoute53Zones = vi.mocked(hydrateAwsRoute53Zones); const mockedHydrateAwsS3BucketAnalyses = vi.mocked(hydrateAwsS3BucketAnalyses); +const mockedHydrateAwsSageMakerNotebookInstances = vi.mocked(hydrateAwsSageMakerNotebookInstances); const mockedHydrateAwsSecretsManagerSecrets = vi.mocked(hydrateAwsSecretsManagerSecrets); const catalog: AwsDiscoveryCatalog = { @@ -1773,6 +1785,69 @@ describe('discoverAwsResources', () => { ]); }); + it('hydrates NAT gateway activity when an active rule requires idle NAT gateway data', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [ + { + accountId: '123456789012', + arn: 'arn:aws:ec2:us-east-1:123456789012:natgateway/nat-123', + properties: [], + region: 'us-east-1', + resourceType: 'ec2:natgateway', + service: 'ec2', + }, + ], + searchRegion: 'us-east-1', + }); + mockedHydrateAwsEc2NatGatewayActivity.mockResolvedValue([ + { + accountId: '123456789012', + bytesInFromDestinationLast7Days: 0, + bytesOutToDestinationLast7Days: 0, + natGatewayId: 'nat-123', + region: 'us-east-1', + state: 'available', + subnetId: 'subnet-123', + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-ec2-nat-gateway-activity'], + service: 'ec2', + }), + ], + { mode: 'region', region: 'us-east-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'us-east-1' }, [ + 'ec2:natgateway', + ]); + expect(mockedHydrateAwsEc2NatGatewayActivity).toHaveBeenCalledWith([ + { + accountId: '123456789012', + arn: 'arn:aws:ec2:us-east-1:123456789012:natgateway/nat-123', + properties: [], + region: 'us-east-1', + resourceType: 'ec2:natgateway', + service: 'ec2', + }, + ]); + expect(result.resources.get('aws-ec2-nat-gateway-activity')).toEqual([ + { + accountId: '123456789012', + bytesInFromDestinationLast7Days: 0, + bytesOutToDestinationLast7Days: 0, + natGatewayId: 'nat-123', + region: 'us-east-1', + state: 'available', + subnetId: 'subnet-123', + }, + ]); + }); + it('hydrates RDS activity summaries when an active rule requires idle-instance data', async () => { mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ indexType: 'LOCAL', @@ -1900,6 +1975,67 @@ describe('discoverAwsResources', () => { ]); }); + it('hydrates SageMaker notebook instances when an active rule requires notebook data', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:notebook-instance/analytics-notebook', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:notebook-instance', + service: 'sagemaker', + }, + ], + searchRegion: 'eu-west-1', + }); + mockedHydrateAwsSageMakerNotebookInstances.mockResolvedValue([ + { + accountId: '123456789012', + instanceType: 'ml.t3.medium', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + notebookInstanceName: 'analytics-notebook', + notebookInstanceStatus: 'InService', + region: 'eu-west-1', + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-sagemaker-notebook-instances'], + service: 'sagemaker', + }), + ], + { mode: 'region', region: 'eu-west-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'eu-west-1' }, [ + 'sagemaker:notebook-instance', + ]); + expect(mockedHydrateAwsSageMakerNotebookInstances).toHaveBeenCalledWith([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:notebook-instance/analytics-notebook', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:notebook-instance', + service: 'sagemaker', + }, + ]); + expect(result.resources.get('aws-sagemaker-notebook-instances')).toEqual([ + { + accountId: '123456789012', + instanceType: 'ml.t3.medium', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + notebookInstanceName: 'analytics-notebook', + notebookInstanceStatus: 'InService', + region: 'eu-west-1', + }, + ]); + }); + it('records a non-fatal diagnostic when one hydrator is access denied and continues loading other datasets', async () => { mockedBuildAwsDiscoveryCatalog.mockResolvedValue(catalog); mockedHydrateAwsEbsVolumes.mockResolvedValue([ diff --git a/packages/sdk/test/providers/aws-ec2-nat-gateways-resource.test.ts b/packages/sdk/test/providers/aws-ec2-nat-gateways-resource.test.ts new file mode 100644 index 0000000..249fe7d --- /dev/null +++ b/packages/sdk/test/providers/aws-ec2-nat-gateways-resource.test.ts @@ -0,0 +1,119 @@ +import type { DescribeNatGatewaysCommand } from '@aws-sdk/client-ec2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createEc2Client } from '../../src/providers/aws/client.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; +import { hydrateAwsEc2NatGatewayActivity } from '../../src/providers/aws/resources/ec2-nat-gateways.js'; + +vi.mock('../../src/providers/aws/client.js', () => ({ + createEc2Client: vi.fn(), +})); + +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + +const mockedCreateEc2Client = vi.mocked(createEc2Client); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); + +const createDailyPoints = (count: number, value: number) => + Array.from({ length: count }, (_, index) => ({ + timestamp: `2026-03-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value, + })); + +describe('hydrateAwsEc2NatGatewayActivity', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates available NAT gateways with 7-day inbound and outbound traffic totals', async () => { + mockedCreateEc2Client.mockReturnValue({ + send: vi.fn(async (_command: DescribeNatGatewaysCommand) => ({ + NatGateways: [ + { + NatGatewayId: 'nat-123', + State: 'available', + SubnetId: 'subnet-123', + }, + { + NatGatewayId: 'nat-456', + State: 'failed', + SubnetId: 'subnet-456', + }, + ], + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + ['natIn0', createDailyPoints(7, 0)], + ['natOut0', createDailyPoints(7, 0)], + ]), + ); + + await expect( + hydrateAwsEc2NatGatewayActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:ec2:us-east-1:123456789012:natgateway/nat-123', + properties: [], + region: 'us-east-1', + resourceType: 'ec2:natgateway', + service: 'ec2', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + bytesInFromDestinationLast7Days: 0, + bytesOutToDestinationLast7Days: 0, + natGatewayId: 'nat-123', + region: 'us-east-1', + state: 'available', + subnetId: 'subnet-123', + }, + ]); + }); + + it('preserves unknown traffic totals when CloudWatch coverage is incomplete', async () => { + mockedCreateEc2Client.mockReturnValue({ + send: vi.fn(async (_command: DescribeNatGatewaysCommand) => ({ + NatGateways: [ + { + NatGatewayId: 'nat-123', + State: 'available', + SubnetId: 'subnet-123', + }, + ], + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + ['natIn0', createDailyPoints(6, 0)], + ['natOut0', createDailyPoints(7, 0)], + ]), + ); + + await expect( + hydrateAwsEc2NatGatewayActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:ec2:us-east-1:123456789012:natgateway/nat-123', + properties: [], + region: 'us-east-1', + resourceType: 'ec2:natgateway', + service: 'ec2', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + bytesInFromDestinationLast7Days: null, + bytesOutToDestinationLast7Days: 0, + natGatewayId: 'nat-123', + region: 'us-east-1', + state: 'available', + subnetId: 'subnet-123', + }, + ]); + }); +}); diff --git a/packages/sdk/test/providers/aws-sagemaker-resource.test.ts b/packages/sdk/test/providers/aws-sagemaker-resource.test.ts new file mode 100644 index 0000000..395456a --- /dev/null +++ b/packages/sdk/test/providers/aws-sagemaker-resource.test.ts @@ -0,0 +1,73 @@ +import type { DescribeNotebookInstanceCommand } from '@aws-sdk/client-sagemaker'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSageMakerClient } from '../../src/providers/aws/client.js'; +import { hydrateAwsSageMakerNotebookInstances } from '../../src/providers/aws/resources/sagemaker.js'; + +vi.mock('../../src/providers/aws/client.js', () => ({ + createSageMakerClient: vi.fn(), +})); + +const mockedCreateSageMakerClient = vi.mocked(createSageMakerClient); + +describe('hydrateAwsSageMakerNotebookInstances', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates discovered notebook instances with status, type, and last modified time', async () => { + mockedCreateSageMakerClient.mockReturnValue({ + send: vi.fn(async (_command: DescribeNotebookInstanceCommand) => ({ + InstanceType: 'ml.t3.medium', + LastModifiedTime: new Date('2026-03-01T00:00:00.000Z'), + NotebookInstanceName: 'analytics-notebook', + NotebookInstanceStatus: 'InService', + })), + } as never); + + await expect( + hydrateAwsSageMakerNotebookInstances([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:notebook-instance/analytics-notebook', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:notebook-instance', + service: 'sagemaker', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + instanceType: 'ml.t3.medium', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + notebookInstanceName: 'analytics-notebook', + notebookInstanceStatus: 'InService', + region: 'eu-west-1', + }, + ]); + }); + + it('skips notebook instances that disappear before hydration completes', async () => { + const error = new Error("Could not find notebook instance 'analytics-notebook'."); + error.name = 'ValidationException'; + + mockedCreateSageMakerClient.mockReturnValue({ + send: vi.fn(async (_command: DescribeNotebookInstanceCommand) => { + throw error; + }), + } as never); + + await expect( + hydrateAwsSageMakerNotebookInstances([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:notebook-instance/analytics-notebook', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:notebook-instance', + service: 'sagemaker', + }, + ]), + ).resolves.toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f267a84..14e3473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1006.0 version: 3.1006.0 + '@aws-sdk/client-sagemaker': + specifier: ^3.1019.0 + version: 3.1019.0 '@aws-sdk/client-secrets-manager': specifier: ^3.1015.0 version: 3.1015.0 @@ -320,6 +323,10 @@ packages: resolution: {integrity: sha512-tm8R/LgWDC3zWPMCdD990owvBrmuIM2A39+OWKW/HyAomWi6ancPz/H1K/hmxf0bqdXAaRUHBQMAmzwb1aR33Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-sagemaker@3.1019.0': + resolution: {integrity: sha512-o+2tK31FeWKeJiD3gDyBWs4qNuZLCp7294sKI0LgZtmlcRRaucCKfJqlZChQ6H37/SOwPquIBDv7wiLjYF9BKw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-secrets-manager@3.1015.0': resolution: {integrity: sha512-gA1XALDCzqMq3Bv2yaUw5rVo3EWneQkRZC/g6dOpo+MVJrVf8T5IErEDSIaBrgJExclyUb4elEl3WNAaTQMioQ==} engines: {node: '>=20.0.0'} @@ -344,6 +351,10 @@ packages: resolution: {integrity: sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.25': + resolution: {integrity: sha512-TNrx7eq6nKNOO62HWPqoBqPLXEkW6nLZQGwjL6lq1jZtigWYbK1NbCnT7mKDzbLMHZfuOECUt3n6CzxjUW9HWQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.4': resolution: {integrity: sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==} engines: {node: '>=20.0.0'} @@ -364,6 +375,10 @@ packages: resolution: {integrity: sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.23': + resolution: {integrity: sha512-EamaclJcCEaPHp6wiVknNMM2RlsPMjAHSsYSFLNENBM8Wz92QPc6cOn3dif6vPDQt0Oo4IEghDy3NMDCzY/IvA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.18': resolution: {integrity: sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==} engines: {node: '>=20.0.0'} @@ -380,6 +395,10 @@ packages: resolution: {integrity: sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.25': + resolution: {integrity: sha512-qPymamdPcLp6ugoVocG1y5r69ScNiRzb0hogX25/ij+Wz7c7WnsgjLTaz7+eB5BfRxeyUwuw5hgULMuwOGOpcw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.16': resolution: {integrity: sha512-hzAnzNXKV0A4knFRWGu2NCt72P4WWxpEGnOc6H3DptUjC4oX3hGw846oN76M1rTHAOwDdbhjU0GAOWR4OUfTZg==} engines: {node: '>=20.0.0'} @@ -400,6 +419,10 @@ packages: resolution: {integrity: sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.26': + resolution: {integrity: sha512-xKxEAMuP6GYx2y5GET+d3aGEroax3AgGfwBE65EQAUe090lzyJ/RzxPX9s8v7Z6qAk0XwfQl+LrmH05X7YvTeg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.16': resolution: {integrity: sha512-VI0kXTlr0o1FTay+Jvx6AKqx5ECBgp7X4VevGBEbuXdCXnNp7SPU0KvjsOLVhIz3OoPK4/lTXphk43t0IVk65w==} engines: {node: '>=20.0.0'} @@ -420,6 +443,10 @@ packages: resolution: {integrity: sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.26': + resolution: {integrity: sha512-EFcM8RM3TUxnZOfMJo++3PnyxFu1fL/huzmn3Vh+8IWRgqZawUD3cRwwOr+/4bE9DpyHaLOWFAjY0lfK5X9ZkQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.17': resolution: {integrity: sha512-98MAcQ2Dk7zkvgwZ5f6fLX2lTyptC3gTSDx4EpvTdJWET8qs9lBPYggoYx7GmKp/5uk0OwVl0hxIDZsDNS/Y9g==} engines: {node: '>=20.0.0'} @@ -440,6 +467,10 @@ packages: resolution: {integrity: sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.27': + resolution: {integrity: sha512-jXpxSolfFnPVj6GCTtx3xIdWNoDR7hYC/0SbetGZxOC9UnNmipHeX1k6spVstf7eWJrMhXNQEgXC0pD1r5tXIg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.16': resolution: {integrity: sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==} engines: {node: '>=20.0.0'} @@ -456,6 +487,10 @@ packages: resolution: {integrity: sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.23': + resolution: {integrity: sha512-IL/TFW59++b7MpHserjUblGrdP5UXy5Ekqqx1XQkERXBFJcZr74I7VaSrQT5dxdRMU16xGK4L0RQ5fQG1pMgnA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.16': resolution: {integrity: sha512-b9of7tQgERxgcEcwAFWvRe84ivw+Kw6b3jVuz/6LQzonkomiY5UoWfprkbjc8FSCQ2VjDqKTvIRA9F0KSQ025w==} engines: {node: '>=20.0.0'} @@ -476,6 +511,10 @@ packages: resolution: {integrity: sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.26': + resolution: {integrity: sha512-c6ghvRb6gTlMznWhGxn/bpVCcp0HRaz4DobGVD9kI4vwHq186nU2xN/S7QGkm0lo0H2jQU8+dgpUFLxfTcwCOg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.16': resolution: {integrity: sha512-PaOH5jFoPQX4WkqpKzKh9cM7rieKtbgEGqrZ+ybGmotJhcvhI/xl69yCwMbHGnpQJJmHZIX9q2zaPB7HTBn/4w==} engines: {node: '>=20.0.0'} @@ -496,6 +535,10 @@ packages: resolution: {integrity: sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.26': + resolution: {integrity: sha512-cXcS3+XD3iwhoXkM44AmxjmbcKueoLCINr1e+IceMmCySda5ysNIfiGBGe9qn5EMiQ9Jd7pP0AGFtcd6OV3Lvg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/dynamodb-codec@3.972.25': resolution: {integrity: sha512-AfkNIk19MOeGXfRyznRMZNOTUt79HU2pl7GP606oUIGELmS495xfvGDGp9nGYbOPxWhXfM7/s51V8+lvmCEwVg==} engines: {node: '>=20.0.0'} @@ -548,6 +591,10 @@ packages: resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.9': + resolution: {integrity: sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-api-gateway@3.972.8': resolution: {integrity: sha512-G5gqn4TPkFdCBo/I9bC1D3GoFLLsRoPDrxxqqzGhLIpmiQvEXIAYmWFV/sJer7ImbWdjsSMW0+/STt8XKbq6BQ==} engines: {node: '>=20.0.0'} @@ -592,6 +639,10 @@ packages: resolution: {integrity: sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.26': + resolution: {integrity: sha512-AilFIh4rI/2hKyyGN6XrB0yN96W2o7e7wyrPWCM6QjZM1mcC/pVkW3IWWRvuBWMpVP8Fg+rMpbzeLQ6dTM4gig==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.10': resolution: {integrity: sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==} engines: {node: '>=20.0.0'} @@ -600,6 +651,10 @@ packages: resolution: {integrity: sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.16': + resolution: {integrity: sha512-L7Qzoj/qQU1cL5GnYLQP5LbI+wlLCLoINvcykR3htKcQ4tzrPf2DOs72x933BM7oArYj1SKrkb2lGlsJHIic3g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.6': resolution: {integrity: sha512-blNJ3ugn4gCQ9ZSZi/firzKCvVl5LvPFVxv24LprENeWI4R8UApG006UQkF4SkmLygKq2BQXRad2/anQ13Te4Q==} engines: {node: '>=20.0.0'} @@ -612,6 +667,10 @@ packages: resolution: {integrity: sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==} engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.10': + resolution: {integrity: sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.7': resolution: {integrity: sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==} engines: {node: '>=20.0.0'} @@ -648,6 +707,10 @@ packages: resolution: {integrity: sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1019.0': + resolution: {integrity: sha512-OF+2RfRmUKyjzrRWlDcyju3RBsuqcrYDQ8TwrJg8efcOotMzuZN4U9mpVTIdATpmEc4lWNZBMSjPzrGm6JPnAQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.5': resolution: {integrity: sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==} engines: {node: '>=20.0.0'} @@ -691,6 +754,15 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.973.12': + resolution: {integrity: sha512-8phW0TS8ntENJgDcFewYT/Q8dOmarpvSxEjATu2GUBAutiHr++oEGCiBUwxslCMNvwW2cAPZNT53S/ym8zm/gg==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/util-user-agent-node@3.973.3': resolution: {integrity: sha512-8s2cQmTUOwcBlIJyI9PAZNnnnF+cGtdhHc1yzMMsSD/GR/Hxj7m0IGUE92CslXXb8/p5Q76iqOCjN1GFwyf+1A==} engines: {node: '>=20.0.0'} @@ -739,6 +811,10 @@ packages: resolution: {integrity: sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.16': + resolution: {integrity: sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.3': resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} engines: {node: '>=18.0.0'} @@ -3643,6 +3719,51 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sagemaker@3.1019.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.25 + '@aws-sdk/credential-provider-node': 3.972.27 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.9 + '@aws-sdk/middleware-user-agent': 3.972.26 + '@aws-sdk/region-config-resolver': 3.972.10 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.12 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-secrets-manager@3.1015.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -3795,6 +3916,22 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@aws-sdk/core@3.973.25': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/xml-builder': 3.972.16 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.972.4': dependencies: '@smithy/types': 4.13.0 @@ -3832,6 +3969,14 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.18': dependencies: '@aws-sdk/core': 3.973.18 @@ -3884,6 +4029,19 @@ snapshots: '@smithy/util-stream': 4.5.20 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/types': 3.973.6 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.16': dependencies: '@aws-sdk/core': 3.973.18 @@ -3979,6 +4137,25 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.26': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/credential-provider-env': 3.972.23 + '@aws-sdk/credential-provider-http': 3.972.25 + '@aws-sdk/credential-provider-login': 3.972.26 + '@aws-sdk/credential-provider-process': 3.972.23 + '@aws-sdk/credential-provider-sso': 3.972.26 + '@aws-sdk/credential-provider-web-identity': 3.972.26 + '@aws-sdk/nested-clients': 3.996.16 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.972.16': dependencies: '@aws-sdk/core': 3.973.18 @@ -4044,6 +4221,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.26': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/nested-clients': 3.996.16 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.972.17': dependencies: '@aws-sdk/credential-provider-env': 3.972.16 @@ -4129,6 +4319,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.27': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.23 + '@aws-sdk/credential-provider-http': 3.972.25 + '@aws-sdk/credential-provider-ini': 3.972.26 + '@aws-sdk/credential-provider-process': 3.972.23 + '@aws-sdk/credential-provider-sso': 3.972.26 + '@aws-sdk/credential-provider-web-identity': 3.972.26 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-process@3.972.16': dependencies: '@aws-sdk/core': 3.973.18 @@ -4165,6 +4372,15 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.23': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.16': dependencies: '@aws-sdk/core': 3.973.18 @@ -4230,6 +4446,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.26': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/nested-clients': 3.996.16 + '@aws-sdk/token-providers': 3.1019.0 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.16': dependencies: '@aws-sdk/core': 3.973.18 @@ -4290,6 +4519,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.26': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/nested-clients': 3.996.16 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/dynamodb-codec@3.972.25': dependencies: '@aws-sdk/core': 3.973.24 @@ -4395,6 +4636,14 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-api-gateway@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -4506,6 +4755,17 @@ snapshots: '@smithy/util-retry': 4.2.12 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.26': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-retry': 4.2.12 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.996.10': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4592,6 +4852,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.996.16': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.25 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.9 + '@aws-sdk/middleware-user-agent': 3.972.26 + '@aws-sdk/region-config-resolver': 3.972.10 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.12 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/nested-clients@3.996.6': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4721,6 +5024,14 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/region-config-resolver@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/config-resolver': 4.4.13 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.7': dependencies: '@aws-sdk/types': 3.973.5 @@ -4814,6 +5125,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1019.0': + dependencies: + '@aws-sdk/core': 3.973.25 + '@aws-sdk/nested-clients': 3.996.16 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.973.5': dependencies: '@smithy/types': 4.13.0 @@ -4878,6 +5201,15 @@ snapshots: '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.973.12': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.26 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.973.3': dependencies: '@aws-sdk/middleware-user-agent': 3.972.18 @@ -4929,6 +5261,12 @@ snapshots: fast-xml-parser: 5.5.8 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.16': + dependencies: + '@smithy/types': 4.13.1 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.3': {} '@babel/helper-string-parser@7.27.1': {}